From 97e7ab1a9ef73c5a24da5f580820b121b846f0ff Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:55 +0800 Subject: [PATCH 01/10] feat(fusion): settlement batching and stats telemetry --- plugins/fusion/scripts/fusion-stats.mjs | 267 +++++++++++++++++-- tests/fusion-stats.test.mjs | 328 +++++++++++++++++++++++- 2 files changed, 564 insertions(+), 31 deletions(-) diff --git a/plugins/fusion/scripts/fusion-stats.mjs b/plugins/fusion/scripts/fusion-stats.mjs index d889bd2..3a6a97e 100644 --- a/plugins/fusion/scripts/fusion-stats.mjs +++ b/plugins/fusion/scripts/fusion-stats.mjs @@ -28,6 +28,11 @@ const FUSION_TASK_ID_PATTERN = /^fusion-[0-9a-f]{24}$/; const ENGINE_JOB_ID_PATTERN = /^[0-9a-f]{32}$/; const RECORD_VERDICTS = new Set(["accepted", "rejected", "unverified"]); const RECORD_SOURCES = new Set(["collector", "main-loop"]); +const FUSION_ACCEPTANCE_EPOCH_ENV = "FUSION_ACCEPTANCE_EPOCH"; +const DEFAULT_ACCEPTANCE_EPOCH = "2026-07-22T00:00:00Z"; +const LAST_SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const LANE_SIGNAL_TRAILING_JOBS = 30; +const LANE_SIGNAL_MIN_JUDGED_JOBS = 10; export function fusionWorkspaceKey(workspaceRoot) { return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 16); @@ -990,6 +995,75 @@ function tokenUsageForJob(raw, observation) { return { availability: incomplete ? "partial" : "unavailable", usage: null }; } +function resolveAcceptanceEpoch(env) { + const configured = nonEmptyString(env[FUSION_ACCEPTANCE_EPOCH_ENV]); + const configuredTimestamp = Date.parse(configured ?? ""); + if (configured && Number.isFinite(configuredTimestamp)) { + return { value: configured, timestamp: configuredTimestamp }; + } + return { value: DEFAULT_ACCEPTANCE_EPOCH, timestamp: Date.parse(DEFAULT_ACCEPTANCE_EPOCH) }; +} + +function codexSku(model, effort) { + return `${model}@${effort ?? "unavailable"}`; +} + +function summarizeCodexSkuTelemetry(entries) { + const newestCreatedAt = entries.reduce((latest, entry) => Math.max(latest, entry.createdAtMs), Number.NEGATIVE_INFINITY); + const trendRows = new Map(); + if (Number.isFinite(newestCreatedAt)) { + const cutoff = newestCreatedAt - LAST_SEVEN_DAYS_MS; + for (const entry of entries) { + if (entry.createdAtMs < cutoff) { + continue; + } + const row = trendRows.get(entry.sku) ?? { sku: entry.sku, jobs: 0, outputTokens: 0, outputTokenOverflow: false, timeouts: 0 }; + row.jobs += 1; + if (entry.failureKind === "timeout") { + row.timeouts += 1; + } + if (entry.outputTokens != null && !row.outputTokenOverflow) { + const outputTokens = row.outputTokens + entry.outputTokens; + if (Number.isSafeInteger(outputTokens)) { + row.outputTokens = outputTokens; + } else { + row.outputTokenOverflow = true; + } + } + trendRows.set(entry.sku, row); + } + } + const terminalBySku = new Map(); + for (const entry of entries) { + if (!entry.terminal) { + continue; + } + const terminalEntries = terminalBySku.get(entry.sku) ?? []; + terminalEntries.push(entry); + terminalBySku.set(entry.sku, terminalEntries); + } + const laneSignalDrift = []; + for (const [sku, terminalEntries] of terminalBySku) { + const trailing = terminalEntries.sort((left, right) => right.createdAtMs - left.createdAtMs).slice(0, LANE_SIGNAL_TRAILING_JOBS); + const judged = trailing.filter((entry) => entry.acceptance === "accepted" || entry.acceptance === "rejected"); + if (judged.length < LANE_SIGNAL_MIN_JUDGED_JOBS) { + continue; + } + const accepted = judged.filter((entry) => entry.acceptance === "accepted").length; + const acceptanceRate = accepted / judged.length; + const timeoutShare = trailing.filter((entry) => entry.failureKind === "timeout").length / trailing.length; + if (acceptanceRate < 0.7 || timeoutShare > 0.2) { + laneSignalDrift.push({ sku, acceptanceRate, timeoutShare }); + } + } + return { + last7DaysBySku: [...trendRows.values()] + .sort((left, right) => left.sku.localeCompare(right.sku)) + .map((row) => ({ ...row, timeoutShare: row.timeouts / row.jobs })), + laneSignalDrift: laneSignalDrift.sort((left, right) => left.sku.localeCompare(right.sku)) + }; +} + export function fileBasedEngineStats(descriptor, { all = false, env = process.env, cwd = process.cwd() } = {}) { const root = resolveStateRoot(descriptor, env); const availableRoots = descriptor.id === "codex" ? resolveCodexStateRoots(env) : [root]; @@ -1035,12 +1109,17 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en let pendingTransportJobs = 0; const acceptedWithErrorTransport = []; const doneWithoutAcceptance = []; + let historicalAcceptanceAnomalies = 0; + const acceptanceEpoch = descriptor.id === "codex" ? resolveAcceptanceEpoch(env) : null; + const codexSkuTelemetryEntries = []; let durationSum = 0; let durationCount = 0; let earliest = null; let latest = null; for (const raw of scoped) { const job = descriptor.normalizeJob(raw); + let model = null; + let effort = null; bump(byStatus, job.status); bump(byKind, job.kind); bump(byEvidence, job.evidence ?? "state"); @@ -1055,11 +1134,27 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en } if (descriptor.includeByModel && typeof descriptor.resolveModel === "function") { const observation = descriptor.usesModelAudit ? observationForJob(raw, env, auditCache) : null; - const model = descriptor.resolveModel(raw, observation); - const effort = typeof descriptor.resolveEffort === "function" ? descriptor.resolveEffort(raw, observation) : null; + model = descriptor.resolveModel(raw, observation); + effort = typeof descriptor.resolveEffort === "function" ? descriptor.resolveEffort(raw, observation) : null; bump(byModel, effort ? `${model}@${effort}` : model); bump(byEffort, effort ?? "unavailable"); } + let codexUsage = null; + if (descriptor.id === "codex") { + const observation = raw?.id ? scopedObservationForJob(tokenObservations, raw, observationRepositoryCache) : null; + codexUsage = tokenUsageForJob(raw, observation); + const createdAtMs = Date.parse(job.createdAt ?? ""); + if (Number.isFinite(createdAtMs)) { + codexSkuTelemetryEntries.push({ + sku: codexSku(model ?? "unknown", effort), + createdAtMs, + terminal: CODEX_TERMINAL_STATUSES.has(job.status), + acceptance: semanticAcceptance(raw), + failureKind: nonEmptyString(raw?.failureKind), + outputTokens: codexUsage.usage?.outputTokens ?? null + }); + } + } if (descriptor.id === "codex") { const jobId = nonEmptyString(raw?.id); if (CODEX_TERMINAL_STATUSES.has(job.status)) { @@ -1067,17 +1162,26 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en } else { pendingTransportJobs += 1; } + const historical = Number.isFinite(Date.parse(raw?.finishedAt ?? "")) && Date.parse(raw.finishedAt) < acceptanceEpoch.timestamp; if (jobId && job.status === "error" && semanticAcceptance(raw) === "accepted") { - acceptedWithErrorTransport.push(jobId); + if (historical) { + historicalAcceptanceAnomalies += 1; + } else { + acceptedWithErrorTransport.push(jobId); + } } if (jobId && job.status === "done" && semanticAcceptance(raw) === "unverified") { - doneWithoutAcceptance.push(jobId); + if (historical) { + historicalAcceptanceAnomalies += 1; + } else { + doneWithoutAcceptance.push(jobId); + } } } if (descriptor.includeTokenUsage && descriptor.isTerminal(raw)) { const observation = descriptor.usesTokenUsageObservations && raw?.id ? scopedObservationForJob(tokenObservations, raw, observationRepositoryCache) : null; - const usageResult = typeof descriptor.resolveTokenUsage === "function" ? descriptor.resolveTokenUsage(raw) : tokenUsageForJob(raw, observation); - if (usageResult.usage) { + const usageResult = codexUsage ?? (typeof descriptor.resolveTokenUsage === "function" ? descriptor.resolveTokenUsage(raw) : tokenUsageForJob(raw, observation)); + if (usageResult.usage) { jobsWithTokenUsage += 1; if (!tokenAggregationOverflow) { const nextTotals = checkedUsageAddition(tokenTotals, usageResult.usage); @@ -1110,6 +1214,7 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en } } } + const codexSkuTelemetry = descriptor.id === "codex" ? summarizeCodexSkuTelemetry(codexSkuTelemetryEntries) : null; return { available: true, scope: all ? "all" : workspaceRoot, @@ -1123,10 +1228,13 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en pendingTransportJobs, acceptanceAnomalies: { acceptedWithErrorTransport, - doneWithoutAcceptance + doneWithoutAcceptance, + ...(historicalAcceptanceAnomalies > 0 ? { historicalPreEpoch: historicalAcceptanceAnomalies, historicalEpoch: acceptanceEpoch.value } : {}) } } : {}), + ...(codexSkuTelemetry?.last7DaysBySku.length ? { last7DaysBySku: codexSkuTelemetry.last7DaysBySku } : {}), + ...(codexSkuTelemetry?.laneSignalDrift.length ? { laneSignalDrift: codexSkuTelemetry.laneSignalDrift } : {}), byKind, ...(typeof descriptor.resolveMode === "function" ? { byMode } : {}), ...(typeof descriptor.resolveFailureKind === "function" ? { byFailureKind } : {}), @@ -1931,7 +2039,8 @@ function renderCounts(lines, title, map) { function renderAcceptanceAnomalies(lines, anomalies) { const acceptedWithErrorTransport = anomalies?.acceptedWithErrorTransport ?? []; const doneWithoutAcceptance = anomalies?.doneWithoutAcceptance ?? []; - if (acceptedWithErrorTransport.length === 0 && doneWithoutAcceptance.length === 0) { + const historicalPreEpoch = anomalies?.historicalPreEpoch ?? 0; + if (acceptedWithErrorTransport.length === 0 && doneWithoutAcceptance.length === 0 && historicalPreEpoch === 0) { return; } lines.push("", "Acceptance anomalies:"); @@ -1941,6 +2050,33 @@ function renderAcceptanceAnomalies(lines, anomalies) { if (doneWithoutAcceptance.length > 0) { lines.push(`- Done jobs without acceptance records: ${doneWithoutAcceptance.length} (${doneWithoutAcceptance.join(", ")})`); } + if (historicalPreEpoch > 0) { + lines.push(`- historical pre-epoch (before ${anomalies.historicalEpoch}): ${historicalPreEpoch}`); + } +} + +function formatShare(value) { + return `${(value * 100).toFixed(1)}%`; +} + +function renderCodexSkuTrend(lines, rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + lines.push("", "By SKU, last 7 days:", "SKU | jobs | output tokens | timeout share"); + for (const row of rows) { + lines.push(`${row.sku} | ${row.jobs} | ${row.outputTokenOverflow ? "overflow" : row.outputTokens} | ${formatShare(row.timeoutShare)}`); + } +} + +function renderLaneSignalDrift(lines, rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + lines.push("", "## Lane signal drift"); + for (const row of rows) { + lines.push(`- ${row.sku}: judged acceptance ${formatShare(row.acceptanceRate)}, timeout share ${formatShare(row.timeoutShare)}; re-score via /fusion:config`); + } } function renderEngine(lines, name, stats) { @@ -1969,6 +2105,7 @@ function renderEngine(lines, name, stats) { renderCounts(lines, "By kind", stats.byKind ?? {}); renderCounts(lines, "By model", stats.byModel ?? {}); renderCounts(lines, "By effort", stats.byEffort ?? {}); + renderCodexSkuTrend(lines, stats.last7DaysBySku); renderCounts(lines, "By failure kind", stats.byFailureKind ?? {}); if (Number.isSafeInteger(stats.harnessAsyncDeliveries)) { lines.push("", `Harness async deliveries: ${stats.harnessAsyncDeliveries}`); @@ -2105,6 +2242,29 @@ function summarizeFleetBursts(bursts) { }; } +function narrowWaveWatch(dispatchEvents) { + const byDaySession = new Map(); + for (const event of dispatchEvents) { + const session = nonEmptyString(event?.session); + const at = nonEmptyString(event?.at); + const atMs = at ? Date.parse(at) : Number.NaN; + if (!session || !Number.isFinite(atMs)) { + continue; + } + const day = at.slice(0, 10); + const key = `${day}\u0000${session}`; + const events = byDaySession.get(key) ?? []; + events.push({ at, atMs, session, day }); + byDaySession.set(key, events); + } + return [...byDaySession.values()] + .filter((events) => events.length >= 8) + .map((events) => ({ day: events[0].day, session: events[0].session, dispatches: events.length, bursts: clusterDispatchBursts(events) })) + .filter((entry) => entry.bursts.every((burst) => burst.width === 1)) + .sort((left, right) => left.day.localeCompare(right.day) || left.session.localeCompare(right.session)) + .map(({ day, session, dispatches }) => ({ day, session, dispatches })); +} + /** * Derive ultra / fleet visibility from inline-guard audit dispatch events. * Pure read-side: clusters same-session dispatches within a short window into bursts. @@ -2120,7 +2280,9 @@ export function buildFleetUsageStats({ env = process.env } = {}) { if (dispatches.length === 0) { return emptyFleetUsageStats(); } - return summarizeFleetBursts(clusterDispatchBursts(dispatches)); + const fleet = summarizeFleetBursts(clusterDispatchBursts(dispatches)); + const watch = narrowWaveWatch(dispatches); + return watch.length > 0 ? { ...fleet, narrowWaveWatch: watch } : fleet; } function renderFleetUsageStats(lines, fleet) { @@ -2150,6 +2312,12 @@ function renderFleetUsageStats(lines, fleet) { lines.push(`- ${day}: ${dayStats.bursts} burst${dayStats.bursts === 1 ? "" : "s"}, ${dayStats.fleetShapedBursts} fleet-shaped${widths ? ` [${widths}]` : ""}${widest}`); } } + if (Array.isArray(fleet.narrowWaveWatch) && fleet.narrowWaveWatch.length > 0) { + lines.push("", "Narrow-wave watch:"); + for (const watch of fleet.narrowWaveWatch) { + lines.push(`- ${watch.day}, ${watch.session}, ${watch.dispatches}, all width 1`); + } + } } export function buildFusionStats({ all = false, env = process.env, cwd = process.cwd() } = {}) { @@ -2172,6 +2340,7 @@ export function renderFusionStats(report) { renderEngine(lines, provider.displayName, report[provider.id]); } } + renderLaneSignalDrift(lines, report.codex?.laneSignalDrift); renderFleetUsageStats(lines, report.fleet); lines.push("", "Peer token totals include only jobs with exact reported usage. Unavailable jobs are never estimated."); return `${lines.join("\n")}\n`; @@ -2292,10 +2461,11 @@ function parseRecordPair(value) { return { id, verdict, acceptFailedTransport }; } -function parseRecordArguments(argv) { +function parseRecordArguments(argv, { allowPerPairReasons = false } = {}) { const records = []; let source = null; let reason = null; + const reasonsById = new Map(); let asJson = false; let acceptFailedTransport = false; for (let index = 0; index < argv.length; index += 1) { @@ -2305,6 +2475,22 @@ function parseRecordArguments(argv) { index += 1; continue; } + if (token === "--reason-for") { + const id = argv[index + 1]; + const pairReason = argv[index + 2]; + if (!allowPerPairReasons) { + throw new TypeError("--reason-for is available only through the raw-args transport."); + } + if ((!FUSION_TASK_ID_PATTERN.test(id) && !ENGINE_JOB_ID_PATTERN.test(id)) || typeof pairReason !== "string" || !pairReason || reasonsById.has(id)) { + throw new TypeError("--reason-for requires one record id and one reason text."); + } + if (records.at(-1)?.id !== id) { + throw new TypeError("--reason-for must immediately follow its --record pair."); + } + reasonsById.set(id, pairReason); + index += 2; + continue; + } if (token === "--source") { if (source !== null || !RECORD_SOURCES.has(argv[index + 1])) { throw new TypeError("--source must be collector or main-loop."); @@ -2338,9 +2524,23 @@ function parseRecordArguments(argv) { throw new TypeError("At least one --record = pair is required."); } if (reason !== null && records.length !== 1) { - throw new TypeError("--reason can be used only when exactly one --record pair is present."); + throw new TypeError("--reason can be used only when exactly one --record pair is present. Use --reason-for for each rejected pair in a batch."); } - return { records, source: source ?? "main-loop", reason, asJson, acceptFailedTransport }; + if (reason !== null && reasonsById.size > 0) { + throw new TypeError("Use either --reason or --reason-for, not both."); + } + for (const record of records) { + if (record.verdict !== "rejected" && reasonsById.has(record.id)) { + throw new TypeError(`--reason-for applies only to rejected record ${record.id}.`); + } + record.reason = reason ?? reasonsById.get(record.id) ?? null; + if (record.verdict === "rejected") { + if (record.reason === null) { + throw new TypeError(`Rejected verdict for ${record.id} requires --reason for a single pair or --reason-for for a batch.`); + } + } + } + return { records, source: source ?? "main-loop", asJson, acceptFailedTransport }; } function isStrictDirectRecordArguments(argv) { @@ -2443,10 +2643,38 @@ function settleEngineRecord({ jobId, verdict, source, reason, acceptFailedTransp return writes; } -function settleRecords({ records, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout, stderr }) { +function validateWorkerSettlement({ taskId, verdict, source, reason, acceptFailedTransport, env }) { + const current = readWorkerRecord(taskId, env); + validateWorkerAcceptance({ record: current, taskId, acceptance: verdict, source, reason, acceptFailedTransport }); + const peerJobId = isTerminalWorkerStatus(current?.transportStatus) && typeof current.peerJobId === "string" && ENGINE_JOB_ID_PATTERN.test(current.peerJobId) ? current.peerJobId : null; + if (peerJobId && current.peerEngine !== "codex" && current.peerEngine !== "grok") { + resolveEngineJob(peerJobId, env); + } +} + +function validateEngineSettlement({ jobId, verdict, source, reason, acceptFailedTransport, env }) { + resolveEngineJob(jobId, env); + for (const record of readWorkerRecords(env).filter((candidate) => candidate.peerJobId === jobId)) { + validateWorkerAcceptance({ record, taskId: record.taskId, acceptance: verdict, source, reason, acceptFailedTransport }); + } +} + +function validateRecordSettlements({ records, source, acceptFailedTransport, env }) { + for (const { id, verdict, reason = null, acceptFailedTransport: pairAcceptFailedTransport } of records) { + const pairOverride = acceptFailedTransport || pairAcceptFailedTransport; + if (FUSION_TASK_ID_PATTERN.test(id)) { + validateWorkerSettlement({ taskId: id, verdict, source, reason, acceptFailedTransport: pairOverride, env }); + } else { + validateEngineSettlement({ jobId: id, verdict, source, reason, acceptFailedTransport: pairOverride, env }); + } + } +} + +function settleRecords({ records, source, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout, stderr }) { + validateRecordSettlements({ records, source, acceptFailedTransport, env }); const writes = []; const errors = []; - for (const { id, verdict, acceptFailedTransport: pairAcceptFailedTransport } of records) { + for (const { id, verdict, reason = null, acceptFailedTransport: pairAcceptFailedTransport } of records) { const pairOverride = acceptFailedTransport || pairAcceptFailedTransport; try { if (FUSION_TASK_ID_PATTERN.test(id)) { @@ -2463,7 +2691,7 @@ function settleRecords({ records, source, reason, acceptFailedTransport = false, return { writes, errors }; } -export function main(argv = process.argv.slice(2), { env = process.env, cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr } = {}) { +export function main(argv = process.argv.slice(2), { env = process.env, cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr, rawArgs = false } = {}) { const asJson = argv.includes("--json"); if (argv.includes("--audit")) { @@ -2479,7 +2707,7 @@ export function main(argv = process.argv.slice(2), { env = process.env, cwd = pr } if (argv.includes("--record")) { - const request = parseRecordArguments(argv); + const request = parseRecordArguments(argv, { allowPerPairReasons: rawArgs }); const result = settleRecords({ ...request, workspaceRoot: cwd, env, stdout, stderr }); if (result.errors.length > 0) { throw new Error(`${result.errors.length} record settlement ${result.errors.length === 1 ? "failed" : "failures"}.`); @@ -2541,6 +2769,11 @@ function runCli(argv = process.argv.slice(2)) { return; } if (isStrictDirectRecordArguments(argv)) { + if (argv.some((token, index) => token === "--record" && parseRecordPair(argv[index + 1]).verdict === "rejected")) { + process.stderr.write("Rejected verdicts require --reason through the raw-args transport. For batch settlements, use --reason-for for each rejected pair.\n"); + process.exitCode = 1; + return; + } main(argv); return; } @@ -2548,7 +2781,7 @@ function runCli(argv = process.argv.slice(2)) { throw new TypeError("Fusion stats requests must be supplied through --raw-args-token."); } const transport = resolveRawArgsTransport(argv); - main(transport.argv); + main(transport.argv, { rawArgs: true }); } if (isMain()) { diff --git a/tests/fusion-stats.test.mjs b/tests/fusion-stats.test.mjs index 0f82813..8110e31 100644 --- a/tests/fusion-stats.test.mjs +++ b/tests/fusion-stats.test.mjs @@ -40,6 +40,7 @@ import { applyQueuedVerdict, createWorkerRecord, readWorkerRecord, recordWorkerA import { gitIsolation } from "./lib/git-fixture.mjs"; const SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "plugins", "fusion", "scripts", "fusion-stats.mjs"); +const CODEX_MONITOR_SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "plugins", "fusion", "scripts", "codex-jobs-monitor.mjs"); function sandbox(t) { const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "fusion-stats-test-"))); @@ -114,6 +115,24 @@ function runDirect(env, extraArgs = [], extraEnv = {}) { }); } +test("Codex jobs monitor stays silent for an unchanged state directory", (t) => { + const dir = sandbox(t); + const stateRoot = path.join(dir, "codex-state"); + fs.mkdirSync(stateRoot, { recursive: true }); + const result = spawnSync(process.execPath, [CODEX_MONITOR_SCRIPT], { + cwd: dir, + encoding: "utf8", + env: runtimeEnv( + { cwd: dir, codexState: stateRoot }, + { FUSION_CODEX_COMPANION: path.join(dir, "missing-codex-companion.mjs"), CODEX_JOBS_MONITOR_INTERVAL_MS: "1000" } + ), + timeout: 400, + killSignal: "SIGTERM" + }); + + assert.strictEqual(result.stdout, ""); +}); + function writeCodexAcceptanceCompanion(directory) { const companion = path.join(directory, "codex-companion.mjs"); fs.writeFileSync( @@ -138,6 +157,21 @@ function writeCodexAcceptanceCompanion(directory) { return companion; } +function writeSelectiveCodexAcceptanceCompanion(directory, failedJobId) { + const companion = path.join(directory, "selective-codex-companion.mjs"); + fs.writeFileSync( + companion, + [ + "const argv = process.argv.slice(2);", + `if (argv[argv.indexOf("--job-id") + 1] === "${failedJobId}") {`, + ' process.stderr.write("Codex acceptance record update failed.\\n");', + " process.exitCode = 1;", + "}" + ].join("\n") + ); + return companion; +} + function writeGrokAcceptanceCompanion(directory) { const companion = path.join(directory, "grok-companion.mjs"); fs.writeFileSync( @@ -300,6 +334,111 @@ test("Codex stats fail closed when exact token aggregation exceeds the safe inte assert.doesNotMatch(rendered, /Observed tokens:/); }); +test("Codex reports lane signal drift only for SKUs that cross an advisory threshold", (t) => { + const dir = sandbox(t); + const stateRoot = path.join(dir, "state"); + const fusionData = path.join(dir, "fusion"); + for (let index = 0; index < 10; index += 1) { + const rejected = index >= 6; + writeCodexJob(stateRoot, dir, `drift-${index}`, { + status: rejected ? "error" : "done", + jobClass: "task", + acceptance: rejected ? "rejected" : "accepted", + failureKind: index >= 6 && index < 9 ? "timeout" : undefined, + createdAt: `2026-07-20T00:${String(index).padStart(2, "0")}:00.000Z`, + request: { model: "gpt-drift", effort: "xhigh" } + }); + writeCodexJob(stateRoot, dir, `healthy-${index}`, { + status: "done", + jobClass: "task", + acceptance: "accepted", + createdAt: `2026-07-20T01:${String(index).padStart(2, "0")}:00.000Z`, + request: { model: "gpt-healthy", effort: "high" } + }); + } + for (let index = 0; index < 31; index += 1) { + const rejected = index < 10; + writeCodexJob(stateRoot, dir, `trailing-${index}`, { + status: rejected ? "error" : "done", + jobClass: "task", + acceptance: rejected ? "rejected" : "accepted", + createdAt: new Date(Date.parse("2026-07-18T00:00:00.000Z") + index * 60_000).toISOString(), + request: { model: "gpt-trailing", effort: "medium" } + }); + } + + const stats = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }, cwd: dir }); + assert.deepStrictEqual(stats.laneSignalDrift, [{ sku: "gpt-drift@xhigh", acceptanceRate: 0.6, timeoutShare: 0.3 }]); + const rendered = renderFusionStats({ scope: dir, codex: stats }); + assert.match(rendered, /## Lane signal drift\n- gpt-drift@xhigh: judged acceptance 60\.0%, timeout share 30\.0%; re-score via \/fusion:config/); + assert.doesNotMatch(rendered, /gpt-healthy@high: judged acceptance/); + assert.doesNotMatch(rendered, /gpt-trailing@medium: judged acceptance/); + + const healthyRoot = path.join(dir, "healthy-only"); + for (let index = 0; index < 10; index += 1) { + writeCodexJob(healthyRoot, dir, `only-healthy-${index}`, { + status: "done", + jobClass: "task", + acceptance: "accepted", + createdAt: `2026-07-20T02:${String(index).padStart(2, "0")}:00.000Z`, + request: { model: "gpt-healthy", effort: "high" } + }); + } + const healthyStats = codexStats({ env: { FUSION_CODEX_STATE: healthyRoot, FUSION_DATA_DIR: fusionData }, cwd: dir }); + assert.strictEqual(healthyStats.laneSignalDrift, undefined); + assert.doesNotMatch(renderFusionStats({ scope: dir, codex: healthyStats }), /## Lane signal drift/); +}); + +test("Codex renders per-SKU trends from the seven days ending at the newest record", (t) => { + const dir = sandbox(t); + const stateRoot = path.join(dir, "state"); + const fusionData = path.join(dir, "fusion"); + const usage = (outputTokens) => ({ inputTokens: 10, cachedInputTokens: 0, outputTokens, reasoningOutputTokens: 0, totalTokens: 10 + outputTokens }); + writeCodexJob(stateRoot, dir, "outside-window", { + status: "done", + jobClass: "task", + createdAt: "2026-07-12T00:00:00.000Z", + request: { model: "gpt-old", effort: "low" }, + tokenUsageAvailability: "available", + tokenUsage: usage(99) + }); + writeCodexJob(stateRoot, dir, "recent-high", { + status: "done", + jobClass: "task", + createdAt: "2026-07-13T00:00:00.000Z", + request: { model: "gpt-trend", effort: "high" }, + tokenUsageAvailability: "available", + tokenUsage: usage(7) + }); + writeCodexJob(stateRoot, dir, "recent-xhigh", { + status: "done", + jobClass: "task", + createdAt: "2026-07-19T00:00:00.000Z", + request: { model: "gpt-trend", effort: "xhigh" }, + tokenUsageAvailability: "available", + tokenUsage: usage(11) + }); + writeCodexJob(stateRoot, dir, "recent-timeout", { + status: "error", + jobClass: "task", + acceptance: "rejected", + failureKind: "timeout", + createdAt: "2026-07-20T00:00:00.000Z", + request: { model: "gpt-trend", effort: "xhigh" }, + tokenUsageAvailability: "available", + tokenUsage: usage(13) + }); + + const stats = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }, cwd: dir }); + assert.deepStrictEqual(stats.last7DaysBySku, [ + { sku: "gpt-trend@high", jobs: 1, outputTokens: 7, outputTokenOverflow: false, timeouts: 0, timeoutShare: 0 }, + { sku: "gpt-trend@xhigh", jobs: 2, outputTokens: 24, outputTokenOverflow: false, timeouts: 1, timeoutShare: 0.5 } + ]); + const rendered = renderFusionStats({ scope: dir, codex: stats }); + assert.match(rendered, /By SKU, last 7 days:\nSKU \| jobs \| output tokens \| timeout share\ngpt-trend@high \| 1 \| 7 \| 0\.0%\ngpt-trend@xhigh \| 2 \| 24 \| 50\.0%/); + assert.doesNotMatch(rendered, /gpt-old@low \|/); +}); + test("counts canonical running records without repairing or signalling them", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "state"); @@ -1367,7 +1506,7 @@ test("--record leaves a terminal worker unsettled when its engine companion fail assert.strictEqual(worker.awaitingVerdict, true); }); -test("--record resolves a bare Codex job and settles matching worker rows", (t) => { +test("--record resolves a bare Codex job with a raw transport reason and settles matching worker rows", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "codex-state"); const stateDir = path.join(dir, "worker-state"); @@ -1379,9 +1518,10 @@ test("--record resolves a bare Codex job and settles matching worker rows", (t) writeCodexJob(stateRoot, dir, jobId, { status: "done", jobClass: "task" }); createTerminalWorker({ taskId, env, workspaceRoot: dir, peerEngine: "codex", peerJobId: jobId }); - const result = runDirect( + const reason = "The result did not satisfy the requested behavior."; + const result = run( { cwd: dir, codexState: stateRoot }, - ["--record", `${jobId}=rejected`, "--json"], + ["--record", `${jobId}=rejected`, "--reason", reason, "--json"], { ...env, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } ); @@ -1390,8 +1530,28 @@ test("--record resolves a bare Codex job and settles matching worker rows", (t) { kind: "engine", engine: "codex", jobId, acceptance: "rejected" }, { kind: "worker", taskId, acceptance: "rejected" } ]); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected", "--source", "main-loop"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected", "--source", "main-loop", "--reason", reason]); assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "rejected"); + assert.strictEqual(readWorkerRecord(taskId, env).acceptanceReason, reason); +}); + +test("--record refuses a rejected strict direct argument", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const taskId = `fusion-${"d".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId, env, workspaceRoot: dir }); + + const result = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${taskId}=rejected`], + env + ); + + assert.strictEqual(result.status, 1); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(result.stderr, "Rejected verdicts require --reason through the raw-args transport. For batch settlements, use --reason-for for each rejected pair.\n"); + assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "unverified"); }); test("--record resolves a bare Grok job and settles matching worker rows", (t) => { @@ -1436,7 +1596,7 @@ test("--record rejects an engine id that exists in both peer states", (t) => { assert.match(result.stderr, /exists in both Codex and Grok state/); }); -test("--record batches mixed verdicts", (t) => { +test("--record batches mixed verdicts with per-pair rejection reasons", (t) => { const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); const firstTaskId = `fusion-${"2".repeat(24)}`; @@ -1445,9 +1605,9 @@ test("--record batches mixed verdicts", (t) => { createTerminalWorker({ taskId: firstTaskId, env, workspaceRoot: dir }); createTerminalWorker({ taskId: secondTaskId, env, workspaceRoot: dir }); - const result = runDirect( + const result = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record", `${firstTaskId}=accepted`, "--record", `${secondTaskId}=rejected`], + ["--record", `${firstTaskId}=accepted`, "--record", `${secondTaskId}=rejected`, "--reason-for", secondTaskId, "The result did not satisfy the requested behavior."], env ); @@ -1455,6 +1615,29 @@ test("--record batches mixed verdicts", (t) => { assert.strictEqual(result.stdout, `Recorded accepted for Fusion worker task ${firstTaskId}.\nRecorded rejected for Fusion worker task ${secondTaskId}.\n`); assert.strictEqual(readWorkerRecord(firstTaskId, env).acceptance, "accepted"); assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptance, "rejected"); + assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptanceReason, "The result did not satisfy the requested behavior."); +}); + +test("--record rejects a raw batch with a rejected pair that has no reason before writing", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const acceptedTaskId = `fusion-${"4".repeat(24)}`; + const rejectedTaskId = `fusion-${"5".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId: acceptedTaskId, env, workspaceRoot: dir }); + createTerminalWorker({ taskId: rejectedTaskId, env, workspaceRoot: dir }); + + const result = run( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${acceptedTaskId}=accepted`, "--record", `${rejectedTaskId}=rejected`], + env + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`Rejected verdict for ${rejectedTaskId} requires --reason for a single pair or --reason-for for a batch`)); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(readWorkerRecord(acceptedTaskId, env).acceptance, "unverified"); + assert.strictEqual(readWorkerRecord(rejectedTaskId, env).acceptance, "unverified"); }); test("--record queues a nonterminal worker verdict and applies it once terminal", (t) => { @@ -1480,7 +1663,7 @@ test("--record queues a nonterminal worker verdict and applies it once terminal" assert.strictEqual(settled.pendingVerdict, undefined); }); -test("--record continues a batch after a failed transport gate", (t) => { +test("--record rejects a whole batch before writes when a pair fails transport validation", (t) => { const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); const firstTaskId = `fusion-${"8".repeat(24)}`; @@ -1492,9 +1675,9 @@ test("--record continues a batch after a failed transport gate", (t) => { createTerminalWorker({ taskId: thirdTaskId, env, workspaceRoot: dir }); updateWorkerRecord(failedTaskId, env, (record) => ({ ...record, transportStatus: "failed" })); - const result = runDirect( + const result = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record", `${firstTaskId}=accepted`, "--record", `${failedTaskId}=accepted`, "--record", `${thirdTaskId}=rejected`], + ["--record", `${firstTaskId}=accepted`, "--record", `${failedTaskId}=accepted`, "--record", `${thirdTaskId}=rejected`, "--reason-for", thirdTaskId, "The result did not satisfy the requested behavior."], env ); @@ -1502,9 +1685,45 @@ test("--record continues a batch after a failed transport gate", (t) => { assert.match(result.stderr, new RegExp(failedTaskId)); assert.doesNotMatch(result.stderr, new RegExp(firstTaskId)); assert.doesNotMatch(result.stderr, new RegExp(thirdTaskId)); - assert.strictEqual(readWorkerRecord(firstTaskId, env).acceptance, "accepted"); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(readWorkerRecord(firstTaskId, env).acceptance, "unverified"); assert.strictEqual(readWorkerRecord(failedTaskId, env).acceptance, "unverified"); - assert.strictEqual(readWorkerRecord(thirdTaskId, env).acceptance, "rejected"); + assert.strictEqual(readWorkerRecord(thirdTaskId, env).acceptance, "unverified"); +}); + +test("--record continues a batch after a partial engine transport failure and can rerun", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const failedTaskId = `fusion-${"8".repeat(24)}`; + const settledTaskId = `fusion-${"9".repeat(24)}`; + const failedJobId = "a".repeat(32); + const settledJobId = "b".repeat(32); + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId: failedTaskId, env, workspaceRoot: dir, peerEngine: "codex", peerJobId: failedJobId }); + createTerminalWorker({ taskId: settledTaskId, env, workspaceRoot: dir, peerEngine: "codex", peerJobId: settledJobId }); + const args = ["--record", `${failedTaskId}=accepted`, "--record", `${settledTaskId}=accepted`]; + const companion = writeSelectiveCodexAcceptanceCompanion(dir, failedJobId); + + const result = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + args, + { ...env, FUSION_CODEX_COMPANION: companion } + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`Failed to settle ${failedTaskId}`)); + assert.strictEqual(readWorkerRecord(failedTaskId, env).acceptance, "unverified"); + assert.strictEqual(readWorkerRecord(settledTaskId, env).acceptance, "accepted"); + + const retry = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + args, + { ...env, FUSION_CODEX_COMPANION: companion } + ); + + assert.notStrictEqual(retry.status, 0); + assert.strictEqual(readWorkerRecord(failedTaskId, env).acceptance, "unverified"); + assert.strictEqual(readWorkerRecord(settledTaskId, env).acceptance, "accepted"); }); test("--record accepts a per-pair failed transport override", (t) => { @@ -1541,11 +1760,31 @@ test("--record rejects a reason attached to multiple pairs and requires transpor const staged = run({ cwd: dir, codexState: path.join(dir, "missing") }, args, env); assert.notStrictEqual(staged.status, 0); - assert.match(staged.stderr, /--reason can be used only when exactly one --record pair is present/); + assert.match(staged.stderr, /--reason can be used only when exactly one --record pair is present\. Use --reason-for for each rejected pair in a batch/); assert.strictEqual(readWorkerRecord(firstTaskId, env).acceptance, "unverified"); assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptance, "unverified"); }); +test("--record validates every batch pair before writing", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const presentTaskId = `fusion-${"6".repeat(24)}`; + const missingTaskId = `fusion-${"7".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId: presentTaskId, env, workspaceRoot: dir }); + + const result = run( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${presentTaskId}=accepted`, "--record", `${missingTaskId}=accepted`], + env + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`Fusion worker task ${missingTaskId} was not found`)); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(readWorkerRecord(presentTaskId, env).acceptance, "unverified"); +}); + test("Codex reports acceptance anomalies only when the job record and transport state diverge", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "state"); @@ -1564,6 +1803,45 @@ test("Codex reports acceptance anomalies only when the job record and transport assert.match(rendered, /Acceptance anomalies:\n- Accepted ledger entries with error transport: 1 \(eeeeeeee\)\n- Done jobs without acceptance records: 1 \(ffffffffffffffffffffffffffffffff\)/); }); +test("Codex groups pre-epoch acceptance anomalies and falls back from invalid epochs", (t) => { + const dir = sandbox(t); + const stateRoot = path.join(dir, "state"); + const fusionData = path.join(dir, "fusion"); + const historicalAccepted = "a".repeat(32); + const historicalUnverified = "b".repeat(32); + const currentAccepted = "c".repeat(32); + const currentUnverified = "d".repeat(32); + writeCodexJob(stateRoot, dir, historicalAccepted, { status: "error", jobClass: "task", acceptance: "accepted", finishedAt: "2026-07-21T23:59:59.000Z" }); + writeCodexJob(stateRoot, dir, historicalUnverified, { status: "done", jobClass: "task", finishedAt: "2026-07-21T23:59:59.000Z" }); + writeCodexJob(stateRoot, dir, currentAccepted, { status: "error", jobClass: "task", acceptance: "accepted", finishedAt: "2026-07-22T00:00:00.000Z" }); + writeCodexJob(stateRoot, dir, currentUnverified, { status: "done", jobClass: "task", finishedAt: "2026-07-22T00:00:00.000Z" }); + + const baseEnv = { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }; + const defaultStats = codexStats({ env: baseEnv, cwd: dir }); + assert.deepStrictEqual(defaultStats.acceptanceAnomalies, { + acceptedWithErrorTransport: [currentAccepted], + doneWithoutAcceptance: [currentUnverified], + historicalPreEpoch: 2, + historicalEpoch: "2026-07-22T00:00:00Z" + }); + const rendered = renderFusionStats({ scope: dir, codex: defaultStats }); + assert.match(rendered, /Accepted ledger entries with error transport: 1 \(cccccccc\)/); + assert.match(rendered, /Done jobs without acceptance records: 1 \(dddddddddddddddddddddddddddddddd\)/); + assert.match(rendered, /historical pre-epoch \(before 2026-07-22T00:00:00Z\): 2/); + assert.doesNotMatch(rendered, /aaaaaaaa|bbbbbbbb/); + + const fallbackStats = codexStats({ env: { ...baseEnv, FUSION_ACCEPTANCE_EPOCH: "not-an-epoch" }, cwd: dir }); + assert.deepStrictEqual(fallbackStats.acceptanceAnomalies, defaultStats.acceptanceAnomalies); + + const overriddenStats = codexStats({ env: { ...baseEnv, FUSION_ACCEPTANCE_EPOCH: "2026-07-23T00:00:00Z" }, cwd: dir }); + assert.deepStrictEqual(overriddenStats.acceptanceAnomalies, { + acceptedWithErrorTransport: [], + doneWithoutAcceptance: [], + historicalPreEpoch: 4, + historicalEpoch: "2026-07-23T00:00:00Z" + }); +}); + test("generic worker acceptance rejects Codex collectors", (t) => { const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); @@ -1649,7 +1927,7 @@ test("worker acceptance requires --accept-failed-transport only for accepted fai const rejected = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record", `${taskId}=rejected`, "--json"], + ["--record", `${taskId}=rejected`, "--reason", "The result did not satisfy the requested behavior.", "--json"], env ); assert.strictEqual(rejected.status, 0, rejected.stderr); @@ -2304,3 +2582,25 @@ test("fleet usage surface reports zeros without error when the audit dir is empt assert.match(rendered, /Fleet-shaped bursts \(width ≥ 3\): 0/); assert.match(rendered, /Widest burst: none/); }); + +test("fleet usage surface watches sessions with eight narrow dispatch waves", (t) => { + const dir = sandbox(t); + const auditDir = path.join(dir, "inline-guard-audit"); + const day = "2026-07-16"; + const narrowSession = "session-narrow"; + const wideSession = "session-wide"; + const events = []; + for (let index = 0; index < 8; index += 1) { + events.push({ schemaVersion: 1, at: `2026-07-16T10:${String(index).padStart(2, "0")}:00.000Z`, session: narrowSession, event: "dispatch", lane: "codex", tool: "Agent" }); + events.push({ schemaVersion: 1, at: `2026-07-16T11:${String(index * 2).padStart(2, "0")}:00.000Z`, session: wideSession, event: "dispatch", lane: "codex", tool: "Agent" }); + events.push({ schemaVersion: 1, at: `2026-07-16T11:${String(index * 2).padStart(2, "0")}:00.100Z`, session: wideSession, event: "dispatch", lane: "grok", tool: "Agent" }); + } + writeGuardAuditEvents(auditDir, day, events); + + const fleet = buildFleetUsageStats({ env: { FUSION_INLINE_GUARD_AUDIT_DIR: auditDir } }); + assert.deepStrictEqual(fleet.narrowWaveWatch, [{ day, session: narrowSession, dispatches: 8 }]); + const rendered = renderFusionStats({ scope: dir, fleet }); + assert.match(rendered, /By day:[\s\S]*- 2026-07-16:/); + assert.match(rendered, /Narrow-wave watch:\n- 2026-07-16, session-narrow, 8, all width 1/); + assert.doesNotMatch(rendered, /session-wide, 16, all width 1/); +}); From 7bcf58aca489606c43f232372f9864f6f5415a5f Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:55 +0800 Subject: [PATCH 02/10] feat(fusion): worker lifecycle advisories, budgets, and observations --- plugins/fusion/agents/trivial-worker.md | 2 +- plugins/fusion/scripts/lib/worker-state.mjs | 2 + plugins/fusion/scripts/worker-lifecycle.mjs | 315 ++++++++++++++++---- plugins/fusion/verification-manifest.json | 64 ++++ tests/agent-budget-consistency.test.mjs | 4 +- tests/fusion-worker-lifecycle.test.mjs | 274 ++++++++++++++++- 6 files changed, 591 insertions(+), 70 deletions(-) create mode 100644 plugins/fusion/verification-manifest.json diff --git a/plugins/fusion/agents/trivial-worker.md b/plugins/fusion/agents/trivial-worker.md index b055e36..6e27d86 100644 --- a/plugins/fusion/agents/trivial-worker.md +++ b/plugins/fusion/agents/trivial-worker.md @@ -3,7 +3,7 @@ name: trivial-worker description: Fallback tier only for exact, low risk, tiny packages when no eligible peer lane is available or Claude-only tools or privacy are required. Cheapest tier worker pinned to true Haiku for trivial single file edits, renames, small doc fixes, log digestion, and short mechanical checks where speed and cost matter more than depth. Change briefs include a verification command; analysis, drafting, and research briefs include explicit acceptance criteria. Anything ambiguous goes to fast-worker instead. model: claude-haiku-4-5 effort: low -maxTurns: 24 +maxTurns: 32 background: false disallowedTools: Agent --- diff --git a/plugins/fusion/scripts/lib/worker-state.mjs b/plugins/fusion/scripts/lib/worker-state.mjs index 9fe58ce..6c9d71e 100644 --- a/plugins/fusion/scripts/lib/worker-state.mjs +++ b/plugins/fusion/scripts/lib/worker-state.mjs @@ -336,6 +336,8 @@ export function createWorkerRecord(record, env = process.env) { usageAvailability: "unreported", parentTranscriptPath: record.parentTranscriptPath ?? null, parentTranscriptBytesAtDispatch: Number.isSafeInteger(record.parentTranscriptBytesAtDispatch) ? record.parentTranscriptBytesAtDispatch : null, + packageType: record.packageType ?? "consult", + briefBytes: Number.isSafeInteger(record.briefBytes) ? record.briefBytes : null, completionContract: record.completionContract ?? "verification", ...(record.expectedPeerEngine && record.expectedPeerJobId ? { expectedPeerEngine: record.expectedPeerEngine, expectedPeerJobId: record.expectedPeerJobId } : {}), createdAt: record.createdAt ?? now, diff --git a/plugins/fusion/scripts/worker-lifecycle.mjs b/plugins/fusion/scripts/worker-lifecycle.mjs index ae03d67..69755a5 100644 --- a/plugins/fusion/scripts/worker-lifecycle.mjs +++ b/plugins/fusion/scripts/worker-lifecycle.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { recordEngineAcceptance } from "./lib/engine-acceptance.mjs"; +import { appendTokenUsageObservation, fusionRepositoryKey } from "./fusion-stats.mjs"; import { applyQueuedVerdict, backfillWorkerTaskOutputTelemetry, @@ -36,9 +37,13 @@ const STALL_MS_ENV = "FUSION_WORKER_STALL_MS"; const MAX_TURNS_ENV = "FUSION_WORKER_MAX_TURNS"; const MAX_OUTPUT_TOKENS_ENV = "FUSION_WORKER_MAX_OUTPUT_TOKENS"; const MAX_UNCACHED_TOKENS_ENV = "FUSION_WORKER_MAX_UNCACHED_TOKENS"; +const PARENT_CONTEXT_ADVISORY_BYTES_ENV = "FUSION_PARENT_CONTEXT_ADVISORY_BYTES"; +const VERIFICATION_MANIFEST_ENV = "FUSION_VERIFICATION_MANIFEST"; const COLLECTION_RESPONSE_DEBUG_ENV = "FUSION_WORKER_DEBUG_COLLECTION_RESPONSE"; const COLLECTION_RESPONSE_DEBUG_FILE = "worker-collection-response.json"; const DEFAULT_BRIEF_MAX_BYTES = 16 * 1024; +const SIZING_ADVISORY_BYTES = 8 * 1024; +const DEFAULT_PARENT_CONTEXT_ADVISORY_BYTES = 4 * 1024 * 1024; const TASK_NOTIFICATION_SCAN_MAX_BYTES = 4 * 1024 * 1024; const TASK_NOTIFICATION_SCAN_CHUNK_BYTES = 64 * 1024; const TASK_NOTIFICATION_MAX_LINE_BYTES = 1024 * 1024; @@ -84,7 +89,7 @@ function positiveInteger(env, name, fallback) { export function workerLimits(agentType, env = process.env, sizing) { const canonical = canonicalWorkerAgentType(agentType); const defaults = canonical === "fusion:trivial-worker" - ? { wallClockMs: 180_000, stallMs: 90_000, maxTurns: 12, maxOutputTokens: 16_000, maxUncachedTokens: 80_000 } + ? { wallClockMs: 240_000, stallMs: 120_000, maxTurns: 16, maxOutputTokens: 24_000, maxUncachedTokens: 120_000 } : canonical === "fusion:job-collector" ? { wallClockMs: 540_000, stallMs: 540_000, maxTurns: 6, maxOutputTokens: 8_000, maxUncachedTokens: 30_000 } : canonical === "fusion:deep-reasoner" @@ -113,12 +118,12 @@ function denyTool(reason) { return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }; } -function allowAgentWithTaskId(toolInput, taskId) { +function allowAgentWithTaskId(toolInput, taskId, additionalContext = null) { const prompt = promptText(toolInput); const marker = `fusion-task-id: ${taskId}`; const lines = prompt.split(/\r?\n/).filter((line) => !/^fusion-task-id:\s*/i.test(line.trim())); lines.splice(lines[0]?.trim() === "fusion-brief: v1" ? 1 : 0, 0, marker); - return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...toolInput, prompt: lines.join("\n") } } }; + return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...toolInput, prompt: lines.join("\n") }, ...(additionalContext ? { additionalContext } : {}) } }; } function writeOutput(value) { @@ -129,6 +134,107 @@ function promptText(toolInput) { return typeof toolInput?.prompt === "string" ? toolInput.prompt : ""; } +function parentContextAdvisoryBytes(env) { + const raw = env[PARENT_CONTEXT_ADVISORY_BYTES_ENV]; + if (raw === undefined || raw === null || (typeof raw === "string" && !raw.trim())) { + return DEFAULT_PARENT_CONTEXT_ADVISORY_BYTES; + } + const parsed = typeof raw === "number" ? raw : Number(String(raw).trim()); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_PARENT_CONTEXT_ADVISORY_BYTES; +} + +function verificationManifestPath(env) { + const configured = env[VERIFICATION_MANIFEST_ENV]; + return typeof configured === "string" && configured.trim() + ? path.resolve(configured) + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "verification-manifest.json"); +} + +function globToRegExp(glob) { + let pattern = ""; + for (let index = 0; index < glob.length; index += 1) { + const character = glob[index]; + if (character === "*") { + if (glob[index + 1] === "*") { + while (glob[index + 1] === "*") { + index += 1; + } + if (glob[index + 1] === "/") { + pattern += "(?:.*/)?"; + index += 1; + } else { + pattern += ".*"; + } + } else { + pattern += "[^/]*"; + } + } else if (character === "?") { + pattern += "[^/]"; + } else { + pattern += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + } + } + return new RegExp(`^${pattern}$`); +} + +function readVerificationManifest(env) { + try { + const manifest = JSON.parse(fs.readFileSync(verificationManifestPath(env), "utf8")); + if (!Array.isArray(manifest) || !manifest.every((entry) => entry && typeof entry === "object" && Array.isArray(entry.paths) && entry.paths.length > 0 && entry.paths.every((value) => typeof value === "string" && value) && Array.isArray(entry.suites) && entry.suites.length > 0 && entry.suites.every((value) => typeof value === "string" && value))) { + return []; + } + return manifest.map((entry) => ({ paths: entry.paths.map((glob) => globToRegExp(glob)), suites: entry.suites })); + } catch { + return []; + } +} + +function pathsNamedInBrief(prompt) { + const paths = new Set(); + for (const line of prompt.split(/\r?\n/)) { + const field = line.match(/^(?:scope|goal):\s*(.*)$/i); + if (!field) { + continue; + } + for (const match of field[1].matchAll(/(?:\.\/)?(?:[A-Za-z0-9_@.+*?\[\]{}-]+\/)+[A-Za-z0-9_@.+*?\[\]{}-]+/g)) { + paths.add(match[0].replace(/^\.\//, "").replace(/[.,:;!?)\]}]+$/, "")); + } + } + return paths; +} + +function missingVerificationSuites(prompt, env) { + const verification = prompt.split(/\r?\n/).find((line) => /^verification:\s*\S/i.test(line)); + if (!verification) { + return []; + } + const namedPaths = pathsNamedInBrief(prompt); + if (namedPaths.size === 0) { + return []; + } + const required = new Set(); + for (const entry of readVerificationManifest(env)) { + if ([...namedPaths].some((namedPath) => entry.paths.some((pattern) => pattern.test(namedPath)))) { + for (const suite of entry.suites) { + required.add(suite); + } + } + } + return [...required].filter((suite) => !verification.includes(suite)); +} + +function briefAdvisories(prompt, briefBytes, hasSizing, env) { + const advisories = []; + const missingSuites = missingVerificationSuites(prompt, env); + if (missingSuites.length > 0) { + advisories.push(`Verification advisory: add the required suite${missingSuites.length === 1 ? "" : "s"} to \`verification:\`: ${missingSuites.join(", ")}.`); + } + if (briefBytes > SIZING_ADVISORY_BYTES && !hasSizing) { + advisories.push("Sizing advisory: this brief exceeds 8192 bytes without a `sizing:` field. Add `sizing: large` or split the package into smaller briefs."); + } + return advisories; +} + export function validateWorkerBrief(prompt, agentType, env = process.env) { const canonical = canonicalWorkerAgentType(agentType); if (!BRIEF_AGENTS.has(canonical)) { @@ -161,6 +267,16 @@ export function validateWorkerBrief(prompt, agentType, env = process.env) { if (/^fusion-task-id:\s*/im.test(prompt)) { return { ok: false, reason: "`fusion-task-id` is reserved for the lifecycle guard." }; } + const packageTypeLines = prompt.split(/\r?\n/).filter((line) => /^package-type\s*:/i.test(line.trim())); + if (packageTypeLines.length > 1) { + return { ok: false, reason: "Fusion worker brief `package-type:` may appear once and must be one of `implementation`, `consult`, `review`, or `research`." }; + } + const packageType = packageTypeLines.length === 1 + ? packageTypeLines[0].match(/^package-type\s*:\s*(implementation|consult|review|research)\s*$/i)?.[1]?.toLowerCase() + : /^verification:\s*\S/im.test(prompt) ? "implementation" : "consult"; + if (!packageType) { + return { ok: false, reason: "Fusion worker brief `package-type:` must be one of `implementation`, `consult`, `review`, or `research`." }; + } const sizingLines = prompt.split(/\r?\n/).filter((line) => /^sizing\s*:/i.test(line.trim())); if (sizingLines.length > 1) { return { ok: false, reason: "Fusion worker brief `sizing:` may appear once and must be one of `small`, `standard`, or `large`." }; @@ -170,9 +286,11 @@ export function validateWorkerBrief(prompt, agentType, env = process.env) { if (!sizing) { return { ok: false, reason: "Fusion worker brief `sizing:` must be one of `small`, `standard`, or `large`." }; } - return { ok: true, sizing }; + const briefBytes = Buffer.byteLength(prompt); + return { ok: true, sizing, packageType, briefBytes, advisories: briefAdvisories(prompt, briefBytes, true, env) }; } - return { ok: true }; + const briefBytes = Buffer.byteLength(prompt); + return { ok: true, packageType, briefBytes, advisories: briefAdvisories(prompt, briefBytes, false, env) }; } function externalUserText(entry) { @@ -266,9 +384,10 @@ function collectorRequestIdentity(prompt) { return engine && jobId ? { expectedPeerEngine: engine, expectedPeerJobId: jobId } : null; } -function createDispatch(input, agentType, userBackgroundAuthorized, env, sizing) { +function createDispatch(input, agentType, userBackgroundAuthorized, env, validation = {}) { const taskId = createWorkerTaskId(input.session_id, input.tool_use_id); - const collectorIdentity = canonicalWorkerAgentType(agentType) === "fusion:job-collector" ? collectorRequestIdentity(promptText(input.tool_input)) : null; + const prompt = promptText(input.tool_input); + const collectorIdentity = canonicalWorkerAgentType(agentType) === "fusion:job-collector" ? collectorRequestIdentity(prompt) : null; let parentTranscriptBytesAtDispatch = null; try { parentTranscriptBytesAtDispatch = fs.statSync(input.transcript_path).size; @@ -286,12 +405,78 @@ function createDispatch(input, agentType, userBackgroundAuthorized, env, sizing) userBackgroundAuthorized, parentTranscriptPath: typeof input.transcript_path === "string" ? input.transcript_path : null, parentTranscriptBytesAtDispatch, - completionContract: completionContract(agentType, promptText(input.tool_input)), + packageType: validation.packageType ?? (/^verification:\s*\S/im.test(prompt) ? "implementation" : "consult"), + briefBytes: validation.briefBytes ?? null, + completionContract: completionContract(agentType, prompt), ...collectorIdentity, - limits: workerLimits(agentType, env, sizing) + limits: workerLimits(agentType, env, validation.sizing) }, env); } +function claimParentContextAdvisory(record, env) { + const threshold = parentContextAdvisoryBytes(env); + if (threshold === 0 || !Number.isSafeInteger(record.parentTranscriptBytesAtDispatch) || record.parentTranscriptBytesAtDispatch <= threshold) { + return false; + } + let claimed = false; + try { + updateWorkerSessionState(record.sessionId, env, (current) => { + if (current?.parentContextAdvisorySent === true) { + return null; + } + claimed = true; + return { ...(current ?? {}), parentContextAdvisorySent: true }; + }); + } catch { + return false; + } + return claimed; +} + +function observedWorkerTokenUsage(usage) { + return { + inputTokens: nonNegativeInteger(usage?.inputTokens), + cachedInputTokens: nonNegativeInteger(usage?.cacheReadInputTokens), + outputTokens: nonNegativeInteger(usage?.outputTokens), + reasoningOutputTokens: null, + totalTokens: nonNegativeInteger(usage?.totalTokens) + }; +} + +function observeTerminalTokenUsage(record, env) { + if (!record || !isTerminalWorkerStatus(record.transportStatus) || record.usageAvailability !== "available" || typeof record.workspaceRoot !== "string" || !record.workspaceRoot) { + return; + } + try { + const repositoryKey = fusionRepositoryKey(record.workspaceRoot); + if (!repositoryKey) { + return; + } + appendTokenUsageObservation(path.join(resolveFusionDataDir(env), "observations", repositoryKey, "token-usage.jsonl"), { + schemaVersion: 1, + jobId: record.taskId, + engine: "claude", + workspaceRoot: record.workspaceRoot, + repositoryKey, + availability: record.usageAvailability, + tokenUsage: observedWorkerTokenUsage(record.usage), + reason: null, + threadId: null, + turnId: null, + source: "worker-record", + observedAt: new Date().toISOString() + }); + } catch { + void 0; + } +} + +function updateLifecycleWorkerRecord(taskId, env, updater) { + const updated = updateWorkerRecord(taskId, env, updater); + observeTerminalTokenUsage(updated, env); + return updated; +} + function pendingDispatchForStart(input, env) { const canonical = canonicalWorkerAgentType(input.agent_type); const pending = readWorkerRecords(env, { strict: true }).filter((record) => record.sessionId === input.session_id && canonicalWorkerAgentType(record.agentType) === canonical && !isTerminalWorkerStatus(record.transportStatus) && !record.agentId); @@ -306,7 +491,7 @@ function recordForAgent(input, env) { if (taskId) { const exact = readWorkerRecords(env, { strict: true }).find((record) => record.taskId === taskId && record.sessionId === input.session_id && canonicalWorkerAgentType(record.agentType) === canonicalWorkerAgentType(input.agent_type)); if (exact) { - return updateWorkerRecord(exact.taskId, env, (current) => ({ ...current, agentId: input.agent_id })); + return updateLifecycleWorkerRecord(exact.taskId, env, (current) => ({ ...current, agentId: input.agent_id })); } } return findWorkerRecord((record) => record.agentId === input.agent_id, env, { strict: true }); @@ -345,7 +530,7 @@ function refreshRecord(record, input, env) { if (!transcriptPath) { return record; } - return updateWorkerRecord(record.taskId, env, (current) => refreshWorkerTranscript(current ?? record, transcriptPath)); + return updateLifecycleWorkerRecord(record.taskId, env, (current) => refreshWorkerTranscript(current ?? record, transcriptPath)); } export function workerBudgetFailure(record, now = Date.now()) { @@ -376,7 +561,7 @@ export function workerBudgetFailure(record, now = Date.now()) { } function markBudgetFailure(record, failure, env) { - return updateWorkerRecord(record.taskId, env, (current) => { + return updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || isTerminalWorkerStatus(current.transportStatus)) { return null; } @@ -502,8 +687,12 @@ function handlePreToolUse(input, env) { writeOutput(denyTool("Fusion workers may detach only when the latest user message explicitly contains `--background`. Remove background mode and collect the result in this turn.")); return; } - const record = createDispatch(input, agentType, userBackgroundAuthorized, env, validation.sizing); - writeOutput(allowAgentWithTaskId(input.tool_input, record.taskId)); + const record = createDispatch(input, agentType, userBackgroundAuthorized, env, validation); + const advisories = [...(validation.advisories ?? [])]; + if (claimParentContextAdvisory(record, env)) { + advisories.push("The orchestrator transcript is large. Cache reread cost grows with context size times turns. Consider a fresh session for the next goal."); + } + writeOutput(allowAgentWithTaskId(input.tool_input, record.taskId, advisories.join("\n\n") || null)); return; } if (!isFusionWorkerAgent(input.agent_type)) { @@ -524,7 +713,7 @@ function handlePreToolUse(input, env) { return; } const now = new Date().toISOString(); - updateWorkerRecord(refreshed.taskId, env, (current) => { + updateLifecycleWorkerRecord(refreshed.taskId, env, (current) => { if (!current || isTerminalWorkerStatus(current.transportStatus)) { return null; } @@ -778,7 +967,7 @@ function handlePostToolUse(input, env, failed = false) { if (["TaskOutput", "TaskStop"].includes(input.tool_name) && taskId && noTaskFoundError(input, failed)) { const now = new Date().toISOString(); for (const record of records.filter((candidate) => candidate.sessionId === input.session_id && !isTerminalWorkerStatus(candidate.transportStatus) && [candidate.backgroundTaskId, candidate.agentId].includes(taskId))) { - updateWorkerRecord(record.taskId, env, (current) => { + updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { return null; } @@ -813,7 +1002,7 @@ function handlePostToolUse(input, env, failed = false) { captureCollectionResponse(input, env); } for (const record of matches) { - updateWorkerRecord(record.taskId, env, (current) => { + updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || current.sessionId !== input.session_id) { return null; } @@ -862,7 +1051,7 @@ function handlePostToolUse(input, env, failed = false) { const outputFile = outputFileFromLaunchResponse(response, nested); const now = new Date().toISOString(); const record = createDispatch(input, agentType, false, env); - updateWorkerRecord(record.taskId, env, (current) => ({ + updateLifecycleWorkerRecord(record.taskId, env, (current) => ({ ...current, ...(agentId ? { agentId, backgroundTaskId: agentId } : {}), ...(outputFile ? { outputFile } : {}), @@ -889,7 +1078,7 @@ function handlePostToolUse(input, env, failed = false) { const completed = response.status === "completed" || nested.status === "completed"; const totalToolUseCount = response.totalToolUseCount ?? nested.totalToolUseCount; const now = new Date().toISOString(); - updateWorkerRecord(pending.taskId, env, (current) => { + updateLifecycleWorkerRecord(pending.taskId, env, (current) => { if (!current || terminalCollectedRecord(current)) { return null; } @@ -924,7 +1113,7 @@ function handlePostToolUse(input, env, failed = false) { } const now = new Date().toISOString(); let emitWindDown = false; - updateWorkerRecord(record.taskId, env, (current) => { + updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || isTerminalWorkerStatus(current.transportStatus)) { return null; } @@ -957,7 +1146,7 @@ function handleSubagentStart(input, env) { } const now = new Date().toISOString(); let started = false; - const record = updateWorkerRecord(pending.taskId, env, (current) => { + const record = updateLifecycleWorkerRecord(pending.taskId, env, (current) => { if (!current || isTerminalWorkerStatus(current.transportStatus)) { return null; } @@ -996,7 +1185,7 @@ function handleSubagentStop(input, env) { const message = typeof input.last_assistant_message === "string" ? input.last_assistant_message : ""; if (terminalCollectedRecord(refreshed)) { const peerIdentity = PEER_JOB_FOOTER_AGENTS.has(refreshed.agentType) ? capturedPeerIdentity(refreshed.agentType, message, refreshed.peerEngine) : {}; - updateWorkerRecord(refreshed.taskId, env, (current) => current && terminalCollectedRecord(current) ? peerIdentityBackfill(current, peerIdentity) : null); + updateLifecycleWorkerRecord(refreshed.taskId, env, (current) => current && terminalCollectedRecord(current) ? peerIdentityBackfill(current, peerIdentity) : null); return; } const failure = workerBudgetFailure(refreshed); @@ -1004,7 +1193,7 @@ function handleSubagentStop(input, env) { if (finalTextFile) { try { finalTextFile = writeFinalTextArtifact(refreshed, message, env); - updateWorkerRecord(refreshed.taskId, env, (current) => ({ ...(current ?? refreshed), outputFile: finalTextFile })); + updateLifecycleWorkerRecord(refreshed.taskId, env, (current) => ({ ...(current ?? refreshed), outputFile: finalTextFile })); } catch { finalTextFile = null; } @@ -1016,7 +1205,7 @@ function handleSubagentStop(input, env) { const trustedCollectorReport = collectorIdentityMismatch ? null : collectorReport; const collectorProtocolFailure = (collectorContract && complete && !collectorReport) || collectorIdentityMismatch; if (collectorProtocolFailure && !failure && (refreshed.retryCount ?? 0) < 1 && !input.stop_hook_active) { - updateWorkerRecord(refreshed.taskId, env, (current) => ({ ...current, retryCount: (current.retryCount ?? 0) + 1 })); + updateLifecycleWorkerRecord(refreshed.taskId, env, (current) => ({ ...current, retryCount: (current.retryCount ?? 0) + 1 })); const expected = refreshed.expectedPeerEngine && refreshed.expectedPeerJobId ? ` Repeat collection for exactly engine=${refreshed.expectedPeerEngine} job=${refreshed.expectedPeerJobId}.` : ""; @@ -1024,12 +1213,12 @@ function handleSubagentStop(input, env) { return; } if (!complete && !failure && (refreshed.retryCount ?? 0) < 1 && !input.stop_hook_active) { - updateWorkerRecord(refreshed.taskId, env, (current) => ({ ...current, retryCount: (current.retryCount ?? 0) + 1 })); + updateLifecycleWorkerRecord(refreshed.taskId, env, (current) => ({ ...current, retryCount: (current.retryCount ?? 0) + 1 })); writeOutput(blockStop(retryInstruction(refreshed))); return; } const now = new Date().toISOString(); - updateWorkerRecord(refreshed.taskId, env, (current) => { + updateLifecycleWorkerRecord(refreshed.taskId, env, (current) => { if (!current) { return null; } @@ -1423,7 +1612,7 @@ function reconcileTaskNotifications(input, env) { const now = new Date().toISOString(); for (const [taskId, notification] of scanned.notifications) { for (const record of candidates.filter((candidate) => [candidate.backgroundTaskId, candidate.agentId].includes(taskId))) { - const stamped = updateWorkerRecord(record.taskId, env, (current) => { + const stamped = updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { return null; } @@ -1435,7 +1624,7 @@ function reconcileTaskNotifications(input, env) { if (!stamped || stamped.sessionId !== input.session_id || isTerminalWorkerStatus(stamped.transportStatus) || ![stamped.backgroundTaskId, stamped.agentId].includes(taskId)) { continue; } - updateWorkerRecord(record.taskId, env, (current) => { + updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { return null; } @@ -1458,7 +1647,7 @@ function armInFlightRecords(records, tasks, env) { const armedAt = new Date().toISOString(); const armed = []; for (const record of inFlight) { - const updated = updateWorkerRecord(record.taskId, env, (current) => { + const updated = updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || terminalTransportObserved(current, runtimeTaskForRecord(current, tasks))) { return null; } @@ -1489,6 +1678,10 @@ function collectorResultCommand(record) { return ["codex", "grok"].includes(engine) && /^[a-f0-9]{32}$/.test(jobId ?? "") ? `/${engine}:result ${jobId}` : null; } +function settlementCommand(records) { + return `/fusion:stats --record ${records.map((record) => `${record.taskId}=accepted|rejected|unverified`).join(" --record ")}`; +} + function collectorStopGate(input, env) { const finalMessage = typeof input.last_assistant_message === "string" ? input.last_assistant_message : ""; const collectorFailures = unreportedCollectorFailures(input.session_id, env); @@ -1499,13 +1692,13 @@ function collectorStopGate(input, env) { const acknowledged = finalMessage.includes(record.taskId) && (!jobId || finalMessage.includes(jobId)) && /\buncollected\b/i.test(finalMessage) && (!resultCommand || finalMessage.includes(resultCommand)); if (acknowledged) { const reportedAt = new Date().toISOString(); - updateWorkerRecord(record.taskId, env, (current) => ({ + updateLifecycleWorkerRecord(record.taskId, env, (current) => ({ ...current, failureReportedAt: reportedAt, ...(current.failureKind === "collection_protocol" ? { protocolReportedAt: reportedAt } : {}) })); } else { - updateWorkerRecord(record.taskId, env, (current) => ({ ...current, failureStopBlockCount: (current.failureStopBlockCount ?? 0) + 1 })); + updateLifecycleWorkerRecord(record.taskId, env, (current) => ({ ...current, failureStopBlockCount: (current.failureStopBlockCount ?? 0) + 1 })); missingFailureReports.push(record); } } @@ -1522,8 +1715,13 @@ function collectorStopGate(input, env) { } const unjudged = unjudgedPeerCollections(input.session_id, env); if (unjudged.length > 0) { - const instructions = unjudged.map((record) => ["codex", "grok"].includes(record.peerEngine) ? `/fusion:stats --record ${record.taskId}=accepted|rejected|unverified` : `a manual resolution because the collection for Fusion task ${record.taskId} needs manual resolution`); - writeOutput(blockStop(`Collected peer transport results still require an explicit semantic judgment before finishing: ${unjudged.map((record) => `${record.peerEngine}:${record.peerJobId} (task=${record.taskId}, transport=${record.peerTransportStatus}, semantic=${record.peerSemanticStatus ?? "unverified"})`).join(", ")}. After checking the requested completion criteria, record each judgment with ${instructions.join(" or ")}.`)); + const settlementRecords = unjudged.filter((record) => ["codex", "grok"].includes(record.peerEngine)); + const manualRecords = unjudged.filter((record) => !["codex", "grok"].includes(record.peerEngine)); + const instructions = [ + settlementRecords.length > 0 ? `record the judgments in one command: ${settlementCommand(settlementRecords)}` : null, + ...manualRecords.map((record) => `complete a manual resolution because the collection for Fusion task ${record.taskId} needs manual resolution`) + ].filter(Boolean); + writeOutput(blockStop(`Collected peer transport results still require an explicit semantic judgment before finishing: ${unjudged.map((record) => `${record.peerEngine}:${record.peerJobId} (task=${record.taskId}, transport=${record.peerTransportStatus}, semantic=${record.peerSemanticStatus ?? "unverified"})`).join(", ")}. After checking the requested completion criteria, ${instructions.join("; ")}.`)); return true; } return false; @@ -1538,9 +1736,8 @@ function writeAcceptanceAdvisory(records, env) { if (unverified.length === 0) { return; } - const commands = unverified.map((record) => `/fusion:stats --record ${record.taskId}=accepted|rejected|unverified`); const workers = unverified.map((record) => `${record.taskId} (${[record.agentType, record.description].filter((value) => typeof value === "string" && value.trim()).join(", ")})`); - writeOutput(hookOutput("Stop", `Acceptance remains unverified for ${unverified.length} collected Fusion worker${unverified.length === 1 ? "" : "s"}: ${workers.join("; ")}. Settle each row with exactly one command: ${commands.join("; ")}. pairs are = with id either a fusion task id (fusion- plus 24 lowercase hex) or an engine job id (32 lowercase hex), verdict one of accepted|rejected|unverified.`)); + writeOutput(hookOutput("Stop", `Acceptance remains unverified for ${unverified.length} collected Fusion worker${unverified.length === 1 ? "" : "s"}: ${workers.join("; ")}. Settle the pending wave in one command: ${settlementCommand(unverified)}. pairs are = with id either a fusion task id (fusion- plus 24 lowercase hex) or an engine job id (32 lowercase hex), verdict one of accepted|rejected|unverified.`)); } function terminalCollectionInstruction(record) { @@ -1562,7 +1759,7 @@ function rereadPendingRecords(records, predicate, env) { function settleOnlyInstruction(record) { const identity = [record.agentType, record.description].filter((value) => typeof value === "string" && value.trim()).join(", "); - return `settle-only: ${record.taskId}: /fusion:stats --record ${record.taskId}=accepted|rejected|unverified${identity ? ` (${identity})` : ""}`; + return `settle-only: ${record.taskId}${identity ? ` (${identity})` : ""}`; } function writeCombinedStopPending(terminalUncollected, settleOnly, env) { @@ -1573,7 +1770,8 @@ function writeCombinedStopPending(terminalUncollected, settleOnly, env) { } const lines = currentTerminalUncollected.map((record) => `${record.taskId}: ${terminalCollectionInstruction(record)}`) .concat(currentSettleOnly.map(settleOnlyInstruction)); - const message = `Fusion worker completion is pending for ${lines.length} records:\n${lines.map((line) => `* ${line}`).join("\n")}\nTransport completion remains unverified until every result and its verification evidence are reviewed. Record accepted or rejected explicitly through /fusion:stats.`; + const settlementInstruction = currentSettleOnly.length > 0 ? ` Settle the pending wave in one command: ${settlementCommand(currentSettleOnly)}.` : ""; + const message = `Fusion worker completion is pending for ${lines.length} records:\n${lines.map((line) => `* ${line}`).join("\n")}\nTransport completion remains unverified until every result and its verification evidence are reviewed. Record accepted or rejected explicitly through /fusion:stats.${settlementInstruction}`; writeOutput(currentTerminalUncollected.length > 0 ? blockStop(message) : hookOutput("Stop", message)); return true; } @@ -1591,26 +1789,35 @@ function writeTerminalCollectionPending(records, env) { return true; } -function inFlightSignature(records) { +function taskSetSignature(records) { return records.map((record) => record.taskId).sort().join(","); } -function shouldWriteInFlightAdvisory(sessionId, signature, env) { - return readWorkerSessionState(sessionId, env)?.inFlightAdvisorySignature !== signature; +function inFlightPendingState(record) { + return terminalCollectionPending(record) ? "awaiting-collection" : "in-flight"; } -function recordInFlightAdvisory(sessionId, signature, env) { - updateWorkerSessionState(sessionId, env, (current) => ({ ...(current ?? {}), inFlightAdvisorySignature: signature })); +function inFlightAdvisorySignature(records) { + return records + .filter((record) => !terminalCollectedRecord(record)) + .map((record) => `${record.taskId}:${inFlightPendingState(record)}`) + .sort() + .join(","); } -function clearInFlightAdvisory(sessionId, env) { +function claimStopAdvisory(sessionId, kind, signature, env) { + let claimed = false; updateWorkerSessionState(sessionId, env, (current) => { - if (!current || !("inFlightAdvisorySignature" in current)) { + if (current?.stopAdvisorySignatures?.[kind] === signature) { return null; } - const { inFlightAdvisorySignature, ...next } = current; - return next; + claimed = true; + return { + ...(current ?? {}), + stopAdvisorySignatures: { ...(current?.stopAdvisorySignatures ?? {}), [kind]: signature } + }; }); + return claimed; } function claimSettleOnlyAdvisory(sessionId, signature, env) { @@ -1648,7 +1855,7 @@ function handleStop(input, env) { const task = runtimeTaskForRecord(record, tasks); const observedTerminal = terminalTransportObserved(record, task); const failure = observedTerminal ? null : workerBudgetFailure(record); - const updated = updateWorkerRecord(record.taskId, env, (current) => { + const updated = updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || isTerminalWorkerStatus(current.transportStatus)) { return null; } @@ -1673,7 +1880,7 @@ function handleStop(input, env) { const now = new Date().toISOString(); const missingRuntimeIds = cancellations.filter((record) => !record.backgroundTaskId && !record.agentId); for (const record of missingRuntimeIds) { - updateWorkerRecord(record.taskId, env, (current) => { + updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || isTerminalWorkerStatus(current.transportStatus)) { return null; } @@ -1690,12 +1897,9 @@ function handleStop(input, env) { } const currentRecords = sessionRecords(input.session_id, env); const inFlight = armInFlightRecords(currentRecords, tasks, env); - if (inFlight.length === 0) { - clearInFlightAdvisory(input.session_id, env); - } const terminalUncollected = currentRecords.filter(terminalCollectionPending); const settleOnly = settleOnlyRecords(currentRecords); - const settleOnlySignature = settleOnly.length > 0 ? inFlightSignature(settleOnly) : null; + const settleOnlySignature = settleOnly.length > 0 ? taskSetSignature(settleOnly) : null; if (!settleOnlySignature) { clearSettleOnlyAdvisory(input.session_id, env); } @@ -1722,10 +1926,9 @@ function handleStop(input, env) { return; } if (inFlight.length > 0) { - const signature = inFlightSignature(inFlight); - if (!input.stop_hook_active && shouldWriteInFlightAdvisory(input.session_id, signature, env)) { + const signature = inFlightAdvisorySignature(currentRecords); + if (!input.stop_hook_active && claimStopAdvisory(input.session_id, "in-flight", signature, env)) { writeOutput(hookOutput("Stop", `Fusion task${inFlight.length === 1 ? "" : "s"} ${inFlight.map((record) => record.taskId).join(", ")} ${inFlight.length === 1 ? "is" : "are"} still in flight. Collection is armed and will be required after terminal notification.`)); - recordInFlightAdvisory(input.session_id, signature, env); } return; } @@ -1734,7 +1937,7 @@ function handleStop(input, env) { function handleSessionEnd(input, env) { const now = new Date().toISOString(); for (const record of activeSessionRecords(input.session_id, env)) { - updateWorkerRecord(record.taskId, env, (current) => { + updateLifecycleWorkerRecord(record.taskId, env, (current) => { if (!current || isTerminalWorkerStatus(current.transportStatus)) { return null; } diff --git a/plugins/fusion/verification-manifest.json b/plugins/fusion/verification-manifest.json new file mode 100644 index 0000000..0a5ded7 --- /dev/null +++ b/plugins/fusion/verification-manifest.json @@ -0,0 +1,64 @@ +[ + { + "paths": [ + "plugins/fusion/scripts/worker-lifecycle.mjs", + "plugins/fusion/scripts/lib/worker-state.mjs" + ], + "suites": [ + "tests/fusion-worker-lifecycle.test.mjs", + "tests/agent-budget-consistency.test.mjs", + "tests/codex-wrapper-contract.test.mjs" + ] + }, + { + "paths": [ + "plugins/fusion/scripts/fusion-stats.mjs", + "plugins/fusion/scripts/lib/engine-acceptance.mjs", + "plugins/fusion/scripts/lib/codex-state-roots.mjs", + "plugins/fusion/scripts/lib/raw-args-transport.mjs" + ], + "suites": [ + "tests/fusion-stats.test.mjs" + ] + }, + { + "paths": [ + "plugins/fusion/scripts/inline-delegation-guard.mjs" + ], + "suites": [ + "tests/inline-delegation-guard.test.mjs" + ] + }, + { + "paths": [ + "plugins/codex/**/*.mjs" + ], + "suites": [ + "tests/codex-companion.test.mjs" + ] + }, + { + "paths": [ + "plugins/grok/**/*.mjs" + ], + "suites": [ + "tests/grok-companion.test.mjs", + "tests/grok-exec-supervision.test.mjs", + "tests/grok-record-acceptance.test.mjs", + "tests/grok-state-lock.test.mjs", + "tests/grok-transport.test.mjs", + "tests/grok-upstream-contract.test.mjs" + ] + }, + { + "paths": [ + "bench/**/*.mjs", + "bench/tasks/**/*" + ], + "suites": [ + "tests/bench-manifest.test.mjs", + "tests/bench-runner.test.mjs", + "tests/bench-selftest.test.mjs" + ] + } +] diff --git a/tests/agent-budget-consistency.test.mjs b/tests/agent-budget-consistency.test.mjs index 37e12dd..289fb8e 100644 --- a/tests/agent-budget-consistency.test.mjs +++ b/tests/agent-budget-consistency.test.mjs @@ -14,9 +14,9 @@ function readMaxTurns(agent) { return Number.parseInt(maxTurns[1], 10); } -// These must stay at 2x the base workerLimits turn budgets in worker-lifecycle.mjs (fast-worker 60, deep-reasoner 30, trivial-worker 12) so a sizing: large brief cannot be undercut by the harness frontmatter cap. +// These must stay at 2x the base workerLimits turn budgets in worker-lifecycle.mjs (fast-worker 60, deep-reasoner 30, trivial-worker 16) so a sizing: large brief cannot be undercut by the harness frontmatter cap. test("agent frontmatter turn caps accommodate large briefs", () => { assert.equal(readMaxTurns("fast-worker"), 120); assert.equal(readMaxTurns("deep-reasoner"), 60); - assert.equal(readMaxTurns("trivial-worker"), 24); + assert.equal(readMaxTurns("trivial-worker"), 32); }); diff --git a/tests/fusion-worker-lifecycle.test.mjs b/tests/fusion-worker-lifecycle.test.mjs index 757c256..afbb824 100644 --- a/tests/fusion-worker-lifecycle.test.mjs +++ b/tests/fusion-worker-lifecycle.test.mjs @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { test } from "node:test"; +import { fusionRepositoryKey } from "../plugins/fusion/scripts/fusion-stats.mjs"; import { createWorkerRecord, readWorkerSessionState, readWorkerRecords, recordWorkerAcceptance, updateWorkerRecord, WORKER_COLLECTION_METHODS } from "../plugins/fusion/scripts/lib/worker-state.mjs"; import { validateWorkerBrief, workerBudgetFailure, workerLimits } from "../plugins/fusion/scripts/worker-lifecycle.mjs"; @@ -364,24 +365,127 @@ test("environment overrides take precedence over brief sizing hints", (t) => { test("trivial worker limits raise token budgets and retain environment overrides", () => { assert.deepStrictEqual(workerLimits("fusion:trivial-worker", {}), { - wallClockMs: 180_000, - stallMs: 90_000, - maxTurns: 12, - maxOutputTokens: 16_000, - maxUncachedTokens: 80_000 + wallClockMs: 240_000, + stallMs: 120_000, + maxTurns: 16, + maxOutputTokens: 24_000, + maxUncachedTokens: 120_000 }); assert.deepStrictEqual(workerLimits("fusion:trivial-worker", { FUSION_WORKER_MAX_OUTPUT_TOKENS: "17000", FUSION_WORKER_MAX_UNCACHED_TOKENS: "90000" }), { - wallClockMs: 180_000, - stallMs: 90_000, - maxTurns: 12, + wallClockMs: 240_000, + stallMs: 120_000, + maxTurns: 16, maxOutputTokens: 17_000, maxUncachedTokens: 90_000 }); }); +test("package-type envelopes validate, default, and persist their byte length", (t) => { + for (const packageType of ["implementation", "consult", "review", "research"]) { + const prompt = `${brief()}package-type: ${packageType}\n`; + const validation = validateWorkerBrief(prompt, "fusion:fast-worker"); + assert.strictEqual(validation.ok, true); + assert.strictEqual(validation.packageType, packageType); + assert.strictEqual(validation.briefBytes, Buffer.byteLength(prompt)); + } + + assert.strictEqual(validateWorkerBrief(brief(), "fusion:fast-worker").packageType, "implementation"); + assert.strictEqual(validateWorkerBrief("fusion-brief: v1\ncontext-mode: isolated\ngoal: inspect one file\nscope: src/a.ts\nacceptance: identify the behavior\n", "fusion:fast-worker").packageType, "consult"); + + const duplicate = validateWorkerBrief(`${brief()}package-type: review\npackage-type: research\n`, "fusion:fast-worker"); + assert.strictEqual(duplicate.ok, false); + assert.match(duplicate.reason, /may appear once/); + + const invalid = validateWorkerBrief(`${brief()}package-type: deployment\n`, "fusion:fast-worker"); + assert.strictEqual(invalid.ok, false); + assert.match(invalid.reason, /implementation.*consult.*review.*research/); + + const box = sandbox(t); + const prompt = `${brief()}package-type: review\n`; + const launched = run(box, dispatch(box, { prompt })); + assert.strictEqual(JSON.parse(launched.stdout).hookSpecificOutput.permissionDecision, "allow"); + assert.strictEqual(record(box).packageType, "review"); + assert.strictEqual(record(box).briefBytes, Buffer.byteLength(prompt)); +}); + +test("verification manifest advisories identify missing suites and stay silent for complete verification", (t) => { + const incomplete = sandbox(t); + const prompt = "fusion-brief: v1\ncontext-mode: isolated\ngoal: update plugins/fusion/scripts/worker-lifecycle.mjs\nscope: plugins/fusion/scripts/worker-lifecycle.mjs\nverification: node --test tests/fusion-worker-lifecycle.test.mjs\n"; + const launched = JSON.parse(run(incomplete, dispatch(incomplete, { prompt })).stdout).hookSpecificOutput; + assert.strictEqual(launched.permissionDecision, "allow"); + assert.match(launched.additionalContext, /Verification advisory/); + assert.match(launched.additionalContext, /tests\/agent-budget-consistency\.test\.mjs/); + assert.match(launched.additionalContext, /tests\/codex-wrapper-contract\.test\.mjs/); + assert.strictEqual(launched.additionalContext.match(/Verification advisory/g).length, 1); + + const complete = sandbox(t); + const completePrompt = `${prompt.trimEnd()} tests/agent-budget-consistency.test.mjs tests/codex-wrapper-contract.test.mjs\n`; + const completeLaunch = JSON.parse(run(complete, dispatch(complete, { prompt: completePrompt })).stdout).hookSpecificOutput; + assert.strictEqual(completeLaunch.permissionDecision, "allow"); + assert.strictEqual(completeLaunch.additionalContext, undefined); +}); + +test("verification manifest failures are silent and the environment override supplies requirements", (t) => { + const missing = sandbox(t); + const prompt = "fusion-brief: v1\ncontext-mode: isolated\ngoal: update plugins/fusion/scripts/worker-lifecycle.mjs\nscope: plugins/fusion/scripts/worker-lifecycle.mjs\nverification: node --test\n"; + const missingLaunch = JSON.parse(run(missing, dispatch(missing, { prompt }), { FUSION_VERIFICATION_MANIFEST: path.join(missing.root, "missing-manifest.json") }).stdout).hookSpecificOutput; + assert.strictEqual(missingLaunch.permissionDecision, "allow"); + assert.strictEqual(missingLaunch.additionalContext, undefined); + + const overridden = sandbox(t); + const manifest = path.join(overridden.root, "verification-manifest.json"); + fs.writeFileSync(manifest, JSON.stringify([{ paths: ["src/custom.mjs"], suites: ["tests/custom.test.mjs"] }])); + const overridePrompt = "fusion-brief: v1\ncontext-mode: isolated\ngoal: update src/custom.mjs\nscope: src/custom.mjs\nverification: node --test\n"; + const overrideLaunch = JSON.parse(run(overridden, dispatch(overridden, { prompt: overridePrompt }), { FUSION_VERIFICATION_MANIFEST: manifest }).stdout).hookSpecificOutput; + assert.strictEqual(overrideLaunch.permissionDecision, "allow"); + assert.match(overrideLaunch.additionalContext, /tests\/custom\.test\.mjs/); +}); + +test("oversized briefs without sizing advise a large package and combine with verification coverage", (t) => { + const sizing = sandbox(t); + const oversizedPrompt = `${brief()}${"x".repeat(8_193)}\n`; + const sizingLaunch = JSON.parse(run(sizing, dispatch(sizing, { prompt: oversizedPrompt })).stdout).hookSpecificOutput; + assert.strictEqual(sizingLaunch.permissionDecision, "allow"); + assert.match(sizingLaunch.additionalContext, /Sizing advisory/); + assert.match(sizingLaunch.additionalContext, /sizing: large/); + assert.match(sizingLaunch.additionalContext, /split the package/); + + const combined = sandbox(t); + const combinedPrompt = `fusion-brief: v1\ncontext-mode: isolated\ngoal: update plugins/fusion/scripts/worker-lifecycle.mjs\nscope: plugins/fusion/scripts/worker-lifecycle.mjs\nverification: node --test tests/fusion-worker-lifecycle.test.mjs\n${"x".repeat(8_193)}\n`; + const combinedLaunch = JSON.parse(run(combined, dispatch(combined, { prompt: combinedPrompt })).stdout).hookSpecificOutput; + assert.strictEqual(combinedLaunch.permissionDecision, "allow"); + assert.match(combinedLaunch.additionalContext, /Verification advisory/); + assert.match(combinedLaunch.additionalContext, /Sizing advisory/); + assert.strictEqual(combinedLaunch.additionalContext.match(/Verification advisory/g).length, 1); +}); + +test("parent context advisory is thresholded, disabled by zero, and emitted once per session", (t) => { + const box = sandbox(t); + fs.writeFileSync(box.transcript, "x".repeat(64), "utf8"); + + const atThreshold = run(box, dispatch(box), { FUSION_PARENT_CONTEXT_ADVISORY_BYTES: "64" }); + assert.strictEqual(JSON.parse(atThreshold.stdout).hookSpecificOutput.additionalContext, undefined); + assert.strictEqual(readWorkerRecords(envFor(box))[0].parentTranscriptBytesAtDispatch, 64); + + const overThreshold = run(box, { ...dispatch(box), tool_use_id: "tool-2" }, { FUSION_PARENT_CONTEXT_ADVISORY_BYTES: "63" }); + const advisory = JSON.parse(overThreshold.stdout).hookSpecificOutput.additionalContext; + assert.match(advisory, /orchestrator transcript is large/); + assert.match(advisory, /Cache reread cost grows with context size times turns/); + assert.match(advisory, /fresh session for the next goal/); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).parentContextAdvisorySent, true); + + const repeated = run(box, { ...dispatch(box), tool_use_id: "tool-3" }, { FUSION_PARENT_CONTEXT_ADVISORY_BYTES: "63" }); + assert.strictEqual(JSON.parse(repeated.stdout).hookSpecificOutput.additionalContext, undefined); + + const disabled = sandbox(t); + fs.writeFileSync(disabled.transcript, "x".repeat(64), "utf8"); + const zero = run(disabled, dispatch(disabled), { FUSION_PARENT_CONTEXT_ADVISORY_BYTES: "0" }); + assert.strictEqual(JSON.parse(zero.stdout).hookSpecificOutput.additionalContext, undefined); +}); + test("the dispatch guard requires a minimal isolated brief and explicit user background authorization", (t) => { const box = sandbox(t); const invalid = run(box, dispatch(box, { prompt: "do everything above" })); @@ -1300,6 +1404,54 @@ test("foreground structured usage is canonical and transport completion remains assert.strictEqual(completed.toolCalls, 3); }); +test("terminal workers emit one repository-scoped token observation", (t) => { + const box = sandbox(t); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + const completion = { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { + status: "completed", + agentId: "observed-worker", + totalTokens: 116, + usage: { input_tokens: 10, cache_creation_input_tokens: 20, cache_read_input_tokens: 80, output_tokens: 6 } + } + }; + run(box, completion); + + const completed = record(box); + const repositoryKey = fusionRepositoryKey(box.cwd); + const sidecar = path.join(envFor(box).FUSION_DATA_DIR, "observations", repositoryKey, "token-usage.jsonl"); + const observations = fs.readFileSync(sidecar, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.strictEqual(observations.length, 1); + const [observation] = observations; + assert.deepStrictEqual({ ...observation, observedAt: "timestamp" }, { + schemaVersion: 1, + jobId: completed.taskId, + engine: "claude", + workspaceRoot: box.cwd, + repositoryKey, + availability: "available", + tokenUsage: { + inputTokens: 10, + cachedInputTokens: 80, + outputTokens: 6, + reasoningOutputTokens: null, + totalTokens: 116 + }, + reason: null, + threadId: null, + turnId: null, + source: "worker-record", + observedAt: "timestamp" + }); + assert.ok(Number.isFinite(Date.parse(observation.observedAt))); + + run(box, completion); + assert.strictEqual(fs.readFileSync(sidecar, "utf8").trim().split("\n").length, 1); +}); + test("Stop advises on collected unverified workers without blocking and stays quiet after acceptance", (t) => { const box = sandbox(t); run(box, dispatch(box)); @@ -1451,6 +1603,102 @@ test("Stop in-flight advisory emits only when the in-flight set changes", (t) => assert.match(JSON.parse(newWave.stdout).hookSpecificOutput.additionalContext, /Collection is armed/); }); +test("Stop re-emits after a terminal task leaves while terminal blocks remain exempt", (t) => { + const box = sandbox(t); + const firstDispatch = dispatch(box); + run(box, firstDispatch); + run(box, { + ...firstDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "a1", resolvedModel: "claude-sonnet-5" } + }); + const running = [{ id: "a1", type: "subagent", status: "running", agent_type: "fusion:fast-worker" }]; + const first = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: running + }); + assert.match(JSON.parse(first.stdout).hookSpecificOutput.additionalContext, /still in flight/); + let expectedSignature = readWorkerRecords(envFor(box)).map((worker) => `${worker.taskId}:in-flight`).sort().join(","); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).stopAdvisorySignatures["in-flight"], expectedSignature); + + const repeat = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: running + }); + assert.strictEqual(repeat.stdout, ""); + + const secondDispatch = { ...dispatch(box, { description: "fix b" }), tool_use_id: "tool-2" }; + run(box, secondDispatch); + run(box, { + ...secondDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "a2", resolvedModel: "claude-sonnet-5" } + }); + running.push({ id: "a2", type: "subagent", status: "running", agent_type: "fusion:fast-worker" }); + const grown = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: running + }); + assert.match(JSON.parse(grown.stdout).hookSpecificOutput.additionalContext, /still in flight/); + expectedSignature = readWorkerRecords(envFor(box)).map((worker) => `${worker.taskId}:in-flight`).sort().join(","); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).stopAdvisorySignatures["in-flight"], expectedSignature); + + const withTerminal = [{ ...running[0], status: "completed" }, running[1]]; + const terminal = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: withTerminal + }); + assert.strictEqual(JSON.parse(terminal.stdout).decision, "block"); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).stopAdvisorySignatures["in-flight"], expectedSignature); + + const terminalRepeat = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: withTerminal + }); + assert.strictEqual(JSON.parse(terminalRepeat.stdout).decision, "block"); + + const firstWorker = readWorkerRecords(envFor(box)).find((worker) => worker.agentId === "a1"); + updateWorkerRecord(firstWorker.taskId, envFor(box), (current) => ({ + ...current, + transportStatus: "done", + collectedAt: new Date().toISOString(), + awaitingCollection: false, + awaitingCollectionArmedAt: null, + awaitingVerdict: true, + awaitingVerdictArmedAt: new Date().toISOString() + })); + recordWorkerAcceptance({ taskId: firstWorker.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + const afterTerminal = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: [running[1]] + }); + assert.match(JSON.parse(afterTerminal.stdout).hookSpecificOutput.additionalContext, /still in flight/); +}); + test("a successfully terminal async worker without a final-text artifact demands TaskOutput", (t) => { const box = sandbox(t); run(box, dispatch(box)); @@ -3537,7 +3785,8 @@ test("Stop combines multi-record collection and settlement instructions", (t) => assert.strictEqual(decision.decision, "block"); assert.match(decision.reason, new RegExp(`Call Read with file_path=${outputFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); assert.doesNotMatch(decision.reason, /no offset or limit/); - assert.match(decision.reason, new RegExp(`settle-only: ${settling.taskId}: /fusion:stats --record ${settling.taskId}=accepted\\|rejected\\|unverified`)); + assert.match(decision.reason, new RegExp(`settle-only: ${settling.taskId}`)); + assert.match(decision.reason, new RegExp(`/fusion:stats --record ${settling.taskId}=accepted\\|rejected\\|unverified`)); }); test("Stop emits a settle-only advisory once for an unchanged pending set", (t) => { @@ -3546,8 +3795,11 @@ test("Stop emits a settle-only advisory once for an unchanged pending set", (t) const second = createSettleOnlyRecord(box, `fusion-${"2".repeat(24)}`); const emitted = stop(box); - assert.match(emitted.stdout, new RegExp(`settle-only: ${first.taskId}`)); - assert.match(emitted.stdout, new RegExp(`settle-only: ${second.taskId}`)); + const advisory = JSON.parse(emitted.stdout).hookSpecificOutput.additionalContext; + assert.match(advisory, new RegExp(`settle-only: ${first.taskId}`)); + assert.match(advisory, new RegExp(`settle-only: ${second.taskId}`)); + assert.strictEqual((advisory.match(/\/fusion:stats --record/g) ?? []).length, 1); + assert.match(advisory, new RegExp(`/fusion:stats --record ${first.taskId}=accepted\\|rejected\\|unverified --record ${second.taskId}=accepted\\|rejected\\|unverified`)); assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature, [first.taskId, second.taskId].sort().join(",")); const repeated = stop(box); From 68f3a12fcfc9643d987ca5058686eb3453be3dfb Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:56 +0800 Subject: [PATCH 03/10] feat(fusion): inline guard tail allowance and zero-dispatch softening --- .../scripts/inline-delegation-guard.mjs | 438 ++++++++++++++++-- tests/inline-delegation-guard.test.mjs | 200 +++++++- 2 files changed, 583 insertions(+), 55 deletions(-) diff --git a/plugins/fusion/scripts/inline-delegation-guard.mjs b/plugins/fusion/scripts/inline-delegation-guard.mjs index 132f432..cf95e89 100644 --- a/plugins/fusion/scripts/inline-delegation-guard.mjs +++ b/plugins/fusion/scripts/inline-delegation-guard.mjs @@ -3,10 +3,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; const STATE_ENV = "FUSION_INLINE_GUARD_STATE"; const BUDGET_ENV = "FUSION_INLINE_WRITE_BUDGET"; +const TAIL_MAX_BYTES_ENV = "FUSION_INLINE_TAIL_MAX_BYTES"; +const TAIL_ALLOWANCE_ENV = "FUSION_INLINE_TAIL_ALLOWANCE"; +const ZERO_DISPATCH_MAX_BYTES_ENV = "FUSION_INLINE_ZERO_DISPATCH_MAX_BYTES"; +const ZERO_DISPATCH_WRITES_ENV = "FUSION_INLINE_ZERO_DISPATCH_WRITES"; const MODE_ENV = "FUSION_INLINE_GUARD_MODE"; const AUDIT_DIR_ENV = "FUSION_INLINE_GUARD_AUDIT_DIR"; const AUDIT_RETENTION_DAYS_ENV = "FUSION_INLINE_GUARD_AUDIT_RETENTION_DAYS"; @@ -16,6 +21,10 @@ const FUSION_DATA_DIR_ENV = "FUSION_DATA_DIR"; const WORKER_STATE_DIR_ENV = "FUSION_WORKER_STATE_DIR"; const FLEET_WAVE_GAP_ENV = "FUSION_FLEET_WAVE_GAP_MS"; const DEFAULT_BUDGET = 5; +const DEFAULT_TAIL_MAX_BYTES = 1024; +const DEFAULT_TAIL_ALLOWANCE = 3; +const DEFAULT_ZERO_DISPATCH_MAX_BYTES = 16384; +const DEFAULT_ZERO_DISPATCH_WRITES = 10; const DEFAULT_AUDIT_RETENTION_DAYS = 180; const DEFAULT_AUDIT_MAX_BYTES = 2 * 1024 * 1024; const DEFAULT_AUDIT_MAX_FILES = 256; @@ -46,7 +55,10 @@ const NO_OP_BASH_COMMANDS = new Set(["", "true", ":"]); const NO_OP_HEARTBEAT_REASON = "Fusion tasks are in flight for this session, so emit a text-only heartbeat instead of this no-op Bash command."; const REAPED_WORKER_REDIRECT_AUDIT_DESCRIPTION = "reaped-worker-redirect"; const NARROW_WAVE_ADVISORY_SUFFIX = "consecutive narrow waves; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ."; +const NARROW_WAVE_ADVISORY_REASON = "narrow-wave-advisory"; const MISSING_LAUNCH_RECOVERY_REASON = "missing-launch-recovered"; +const TAIL_ALLOW_AUDIT_EVENT = "tail-allowed"; +const ZERO_DISPATCH_SOFTENED_AUDIT_EVENT = "zero-dispatch-softened"; function resolveStateDir(env = process.env) { const override = env[STATE_ENV]; @@ -61,6 +73,31 @@ function resolveBudget(env = process.env) { return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_BUDGET; } +function resolveNonnegativeInteger(env, name, fallback) { + const raw = env[name]; + if (raw === undefined || raw === null || (typeof raw === "string" && !raw.trim())) { + return fallback; + } + const parsed = typeof raw === "number" ? raw : Number(String(raw).trim()); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function resolveTailMaxBytes(env = process.env) { + return resolveNonnegativeInteger(env, TAIL_MAX_BYTES_ENV, DEFAULT_TAIL_MAX_BYTES); +} + +function resolveTailAllowance(env = process.env) { + return resolveNonnegativeInteger(env, TAIL_ALLOWANCE_ENV, DEFAULT_TAIL_ALLOWANCE); +} + +function resolveZeroDispatchMaxBytes(env = process.env) { + return resolveNonnegativeInteger(env, ZERO_DISPATCH_MAX_BYTES_ENV, DEFAULT_ZERO_DISPATCH_MAX_BYTES); +} + +function resolveZeroDispatchWrites(env = process.env) { + return resolveNonnegativeInteger(env, ZERO_DISPATCH_WRITES_ENV, DEFAULT_ZERO_DISPATCH_WRITES); +} + function resolveLockTimeoutMs(env = process.env) { const raw = env[LOCK_TIMEOUT_ENV]; if (raw === undefined || raw === null || (typeof raw === "string" && !String(raw).trim())) { @@ -98,14 +135,15 @@ function resolveWorkerStateDir(env = process.env) { return path.join(resolveFusionDataDir(env), "workers"); } -function sessionHasInFlightWorkerTasks(sessionId, env = process.env) { +function inFlightWorkerTaskSignature(sessionId, env = process.env) { const jobsDir = path.join(resolveWorkerStateDir(env), "jobs"); let entries; try { entries = fs.readdirSync(jobsDir, { withFileTypes: true }); } catch { - return false; + return null; } + const workers = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.startsWith("fusion-") || !entry.name.endsWith(".json")) { continue; @@ -120,13 +158,13 @@ function sessionHasInFlightWorkerTasks(sessionId, env = process.env) { } const status = record.transportStatus; if (typeof status === "string" && !WORKER_TERMINAL_STATUSES.has(status)) { - return true; + workers.push(`${record.taskId ?? entry.name}:${status}`); } } catch { continue; } } - return false; + return workers.length > 0 ? workers.sort().join(",") : null; } function extractTaskControlId(toolInput) { @@ -456,15 +494,80 @@ function normalizeAuditEvent(event) { const safePath = sanitizeAuditPath(event.path); return { schemaVersion: AUDIT_SCHEMA_VERSION, at: new Date(atMs).toISOString(), session, event: "write", lane: MAIN_LANE, tool: event.tool, ...(safePath ? { path: safePath } : {}) }; } + if ( + (event.event === TAIL_ALLOW_AUDIT_EVENT || event.event === ZERO_DISPATCH_SOFTENED_AUDIT_EVENT) && + WRITE_TOOLS.has(event.tool) && + event.lane === MAIN_LANE && + Number.isInteger(event.writeCount) && + event.writeCount >= 0 && + Number.isInteger(event.dispatchCount) && + event.dispatchCount >= 0 && + Number.isInteger(event.budget) && + event.budget > 0 && + event.mode === "enforce" + ) { + const safePath = sanitizeAuditPath(event.path); + if (event.event === TAIL_ALLOW_AUDIT_EVENT && Number.isInteger(event.remainingTailSlots) && event.remainingTailSlots >= 0) { + return { + schemaVersion: AUDIT_SCHEMA_VERSION, + at: new Date(atMs).toISOString(), + session, + event: TAIL_ALLOW_AUDIT_EVENT, + lane: MAIN_LANE, + tool: event.tool, + ...(safePath ? { path: safePath } : {}), + writeCount: event.writeCount, + dispatchCount: event.dispatchCount, + budget: event.budget, + mode: "enforce", + remainingTailSlots: event.remainingTailSlots, + ...(event.suppressed === true ? { suppressed: true } : {}) + }; + } + if ( + event.event === ZERO_DISPATCH_SOFTENED_AUDIT_EVENT && + Number.isInteger(event.remainingZeroDispatchWrites) && + event.remainingZeroDispatchWrites >= 0 && + Number.isInteger(event.remainingZeroDispatchBytes) && + event.remainingZeroDispatchBytes >= 0 + ) { + return { + schemaVersion: AUDIT_SCHEMA_VERSION, + at: new Date(atMs).toISOString(), + session, + event: ZERO_DISPATCH_SOFTENED_AUDIT_EVENT, + lane: MAIN_LANE, + tool: event.tool, + ...(safePath ? { path: safePath } : {}), + writeCount: event.writeCount, + dispatchCount: event.dispatchCount, + budget: event.budget, + mode: "enforce", + remainingZeroDispatchWrites: event.remainingZeroDispatchWrites, + remainingZeroDispatchBytes: event.remainingZeroDispatchBytes, + ...(event.suppressed === true ? { suppressed: true } : {}) + }; + } + return null; + } if (event.event === "dispatch" && DELEGATION_TOOLS.has(event.tool)) { const lane = normalizeLane(event.lane); const description = sanitizeAuditText(event.description); return lane ? { schemaVersion: AUDIT_SCHEMA_VERSION, at: new Date(atMs).toISOString(), session, event: "dispatch", lane, tool: event.tool, ...(description ? { description } : {}) } : null; } - if (event.event === "warn" && DELEGATION_TOOLS.has(event.tool) && event.reason === MISSING_LAUNCH_RECOVERY_REASON) { + if (event.event === "warn" && DELEGATION_TOOLS.has(event.tool) && (event.reason === MISSING_LAUNCH_RECOVERY_REASON || event.reason === NARROW_WAVE_ADVISORY_REASON)) { const lane = normalizeLane(event.lane); return lane - ? { schemaVersion: AUDIT_SCHEMA_VERSION, at: new Date(atMs).toISOString(), session, event: "warn", lane, tool: event.tool, reason: MISSING_LAUNCH_RECOVERY_REASON } + ? { + schemaVersion: AUDIT_SCHEMA_VERSION, + at: new Date(atMs).toISOString(), + session, + event: "warn", + lane, + tool: event.tool, + reason: event.reason, + ...(event.suppressed === true ? { suppressed: true } : {}) + } : null; } if ( @@ -482,7 +585,8 @@ function normalizeAuditEvent(event) { lane: MAIN_LANE, tool: event.tool, description: REAPED_WORKER_REDIRECT_AUDIT_DESCRIPTION, - mode: event.mode + mode: event.mode, + ...(event.event === "warn" && event.suppressed === true ? { suppressed: true } : {}) }; } if ( @@ -509,7 +613,8 @@ function normalizeAuditEvent(event) { writeCount: event.writeCount, dispatchCount: event.dispatchCount, budget: event.budget, - mode: event.mode + mode: event.mode, + ...(event.event === "warn" && event.suppressed === true ? { suppressed: true } : {}) }; } return null; @@ -609,6 +714,32 @@ function extractWritePath(toolInput) { return typeof raw === "string" && raw.length > 0 ? raw : null; } +function inlineWriteBytes(value) { + if (!value || typeof value !== "object") { + return 0; + } + if (Array.isArray(value)) { + return value.reduce((total, entry) => total + inlineWriteBytes(entry), 0); + } + let total = 0; + for (const [key, entry] of Object.entries(value)) { + if ((key === "new_string" || key === "content") && typeof entry === "string") { + total += Buffer.byteLength(entry); + } else if (entry && typeof entry === "object") { + total += inlineWriteBytes(entry); + } + } + return total; +} + +function editReplacementBytes(toolInput) { + return typeof toolInput?.new_string === "string" ? Buffer.byteLength(toolInput.new_string) : null; +} + +function writePathSignature(filePath, cwd) { + return createHash("sha256").update(path.resolve(cwd, filePath)).digest("hex"); +} + function isInsideCwd(filePath, cwd) { if (!filePath || !cwd) { return false; @@ -716,6 +847,9 @@ function defaultState(now) { return { writeCount: 0, writesSinceDispatch: 0, + inlineWriteBytes: 0, + tailWritesSinceDispatch: 0, + writtenPathSignatures: [], dispatchEpoch: 0, lastDispatchAt: null, fleetWaveWidth: 0, @@ -724,6 +858,7 @@ function defaultState(now) { dispatches: {}, dispatchLog: [], advisedMultiples: [], + advisorySignatures: {}, createdAt: now, updatedAt: now }; @@ -791,6 +926,26 @@ function normalizeAdvisedMultiples(value) { return [...new Set(value.filter((multiple) => Number.isInteger(multiple) && multiple > 0))]; } +function normalizeWrittenPathSignatures(value) { + if (!Array.isArray(value)) { + return []; + } + return [...new Set(value.filter((signature) => typeof signature === "string" && /^[a-f0-9]{64}$/.test(signature)))]; +} + +function normalizeAdvisorySignatures(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const signatures = {}; + for (const [kind, signature] of Object.entries(value)) { + if (/^[a-z][a-z0-9-]{0,79}$/.test(kind) && typeof signature === "string" && signature.length > 0 && signature.length <= 1024) { + signatures[kind] = signature; + } + } + return signatures; +} + function extractToolUseId(input) { return sanitizeIdentifier(input?.tool_use_id, 200); } @@ -852,7 +1007,12 @@ function normalizeState(existing, now, waveGapMs = DEFAULT_FLEET_WAVE_GAP_MS) { const dispatches = normalizeDispatches(existing.dispatches); const dispatchCount = totalDispatches(dispatches); const writesSinceDispatch = Number.isFinite(existing.writesSinceDispatch) && existing.writesSinceDispatch >= 0 ? Math.floor(existing.writesSinceDispatch) : 0; + const inlineWriteBytes = Number.isSafeInteger(existing.inlineWriteBytes) && existing.inlineWriteBytes >= 0 ? existing.inlineWriteBytes : 0; + const tailWritesSinceDispatch = + Number.isSafeInteger(existing.tailWritesSinceDispatch) && existing.tailWritesSinceDispatch >= 0 ? existing.tailWritesSinceDispatch : 0; + const writtenPathSignatures = normalizeWrittenPathSignatures(existing.writtenPathSignatures); const advisedMultiples = normalizeAdvisedMultiples(existing.advisedMultiples); + const advisorySignatures = normalizeAdvisorySignatures(existing.advisorySignatures); const dispatchLog = pruneExpiredLaunchedDispatches(normalizeDispatchLog(existing.dispatchLog), now); const createdAtMs = Date.parse(existing.createdAt); const lastDispatchAtMs = Date.parse(existing.lastDispatchAt); @@ -865,6 +1025,9 @@ function normalizeState(existing, now, waveGapMs = DEFAULT_FLEET_WAVE_GAP_MS) { return { writeCount, writesSinceDispatch, + inlineWriteBytes, + tailWritesSinceDispatch, + writtenPathSignatures, dispatchEpoch: Number.isInteger(existing.dispatchEpoch) && existing.dispatchEpoch >= 0 ? existing.dispatchEpoch : dispatchCount, lastDispatchAt: Number.isFinite(lastDispatchAtMs) ? new Date(lastDispatchAtMs).toISOString() : null, fleetWaveWidth, @@ -873,16 +1036,41 @@ function normalizeState(existing, now, waveGapMs = DEFAULT_FLEET_WAVE_GAP_MS) { dispatches, dispatchLog, advisedMultiples, + advisorySignatures, createdAt: Number.isFinite(createdAtMs) ? new Date(createdAtMs).toISOString() : now, updatedAt: now }; } -function allowOutput(reason) { +function stableAdvisorySignature(values) { + return createHash("sha256").update(JSON.stringify(values)).digest("hex"); +} + +function claimAdvisory(file, kind, signature, now, waveGapMs, isCurrent = () => true) { + try { + return withStateLock(file, () => { + const state = normalizeState(readState(file), now, waveGapMs); + if (!isCurrent(state)) { + return { skipped: true, suppressed: false }; + } + const suppressed = state.advisorySignatures[kind] === signature; + state.advisorySignatures[kind] = signature; + writeState(file, state); + return { skipped: false, suppressed }; + }); + } catch { + return { skipped: false, suppressed: false }; + } +} + +function allowOutput(reason, additionalContext) { const hookSpecificOutput = { hookEventName: "PreToolUse", permissionDecision: "allow" }; if (reason) { hookSpecificOutput.permissionDecisionReason = reason; } + if (additionalContext) { + hookSpecificOutput.additionalContext = additionalContext; + } return { hookSpecificOutput }; } @@ -906,6 +1094,14 @@ function buildAdvisoryLine(writeCount, dispatchCount) { ); } +function buildTailAllowanceAdvisory(remainingTailSlots) { + return `The inline write budget is exhausted. Tail allowance permits this small Edit to an already touched file; ${remainingTailSlots} tail ${remainingTailSlots === 1 ? "slot" : "slots"} remain in this dispatch window.`; +} + +function buildZeroDispatchAdvisory(remainingWrites, remainingBytes) { + return `The inline write budget is exhausted, but this zero dispatch session remains within its inline relief bounds; ${remainingWrites} ${remainingWrites === 1 ? "write" : "writes"} and ${remainingBytes} ${remainingBytes === 1 ? "byte" : "bytes"} remain before enforcement resumes.`; +} + function runAllowCommand() { process.stdout.write("fusion inline delegation guard: the allow escape hatch is retired; dispatch an Agent or Task to open a new write window, or set FUSION_INLINE_GUARD_MODE=advisory for compatibility\n"); } @@ -999,9 +1195,19 @@ function runHook(env = process.env, input = readHookInput()) { state.dispatchEpoch += 1; state.lastDispatchAt = latestConfirmedDispatchAt(state.dispatchLog); state.writesSinceDispatch = 0; + state.tailWritesSinceDispatch = 0; + state.writtenPathSignatures = []; state.advisedMultiples = []; writeState(file, state); - return { shouldAdvise, streak: state.consecutiveNarrowWaves, lane: launched.lane, description: launched.description ?? description, recoveredMissingLaunch }; + return { + shouldAdvise, + streak: state.consecutiveNarrowWaves, + fleetWaveWidth: state.fleetWaveWidth, + dispatchEpoch: state.dispatchEpoch, + lane: launched.lane, + description: launched.description ?? description, + recoveredMissingLaunch + }; }); if (confirmation.recoveredMissingLaunch) { recordAuditEvent( @@ -1014,7 +1220,31 @@ function runHook(env = process.env, input = readHookInput()) { env ); if (confirmation.shouldAdvise) { - process.stdout.write(`${JSON.stringify(postToolAdvisoryOutput(`${confirmation.streak} ${NARROW_WAVE_ADVISORY_SUFFIX}`))}\n`); + const advisory = claimAdvisory( + file, + "narrow-wave", + stableAdvisorySignature([confirmation.streak, confirmation.fleetWaveWidth]), + now, + waveGapMs, + (state) => state.dispatchEpoch === confirmation.dispatchEpoch && state.consecutiveNarrowWaves === confirmation.streak + ); + if (!advisory.skipped) { + recordAuditEvent( + { + at: now, + session: sessionId, + event: "warn", + lane: confirmation.lane, + tool: toolName, + reason: NARROW_WAVE_ADVISORY_REASON, + ...(advisory.suppressed ? { suppressed: true } : {}) + }, + env + ); + if (!advisory.suppressed) { + process.stdout.write(`${JSON.stringify(postToolAdvisoryOutput(`${confirmation.streak} ${NARROW_WAVE_ADVISORY_SUFFIX}`))}\n`); + } + } } return; } @@ -1030,6 +1260,16 @@ function runHook(env = process.env, input = readHookInput()) { } const mode = resolveMode(env); const reason = reapedWorkerRedirectReason(record, taskId); + const advisory = + mode === "advisory" + ? claimAdvisory( + file, + "reaped-worker-redirect", + stableAdvisorySignature([toolName, taskId, record.taskId ?? null, record.transportStatus, record.outputFile ?? null, record.transcriptPath ?? null]), + now, + resolveFleetWaveGapMs(env) + ) + : { skipped: false, suppressed: false }; recordAuditEvent( { at: now, @@ -1038,11 +1278,14 @@ function runHook(env = process.env, input = readHookInput()) { lane: MAIN_LANE, tool: toolName, description: REAPED_WORKER_REDIRECT_AUDIT_DESCRIPTION, - mode + mode, + ...(advisory.suppressed ? { suppressed: true } : {}) }, env ); - process.stdout.write(`${JSON.stringify(mode === "advisory" ? allowOutput(reason) : denyOutput(reason))}\n`); + if (mode !== "advisory" || !advisory.suppressed) { + process.stdout.write(`${JSON.stringify(mode === "advisory" ? allowOutput(reason) : denyOutput(reason))}\n`); + } return; } @@ -1051,17 +1294,20 @@ function runHook(env = process.env, input = readHookInput()) { return; } const command = extractBashCommand(input.tool_input); - if (command === null || !NO_OP_BASH_COMMANDS.has(command) || !sessionHasInFlightWorkerTasks(sessionId, env)) { + const inFlightSignature = sessionId ? inFlightWorkerTaskSignature(sessionId, env) : null; + if (command === null || !NO_OP_BASH_COMMANDS.has(command) || !inFlightSignature) { return; } const mode = resolveMode(env); const budget = resolveBudget(env); let writeCount = 0; let dispatchCount = 0; + let dispatchEpoch = 0; try { const snapshot = withStateLock(file, () => normalizeState(readState(file), now, resolveFleetWaveGapMs(env))); writeCount = snapshot.writesSinceDispatch; dispatchCount = totalDispatches(snapshot.dispatches); + dispatchEpoch = snapshot.dispatchEpoch; } catch { void 0; } @@ -1076,9 +1322,24 @@ function runHook(env = process.env, input = readHookInput()) { budget, mode }; + const advisory = + mode === "advisory" + ? claimAdvisory( + file, + "heartbeat", + stableAdvisorySignature([inFlightSignature, writeCount, dispatchCount, dispatchEpoch, budget]), + now, + resolveFleetWaveGapMs(env) + ) + : { skipped: false, suppressed: false }; + if (advisory.suppressed) { + auditEvent.suppressed = true; + } recordAuditEvent(auditEvent, env); if (mode === "advisory") { - process.stdout.write(`${JSON.stringify(allowOutput(NO_OP_HEARTBEAT_REASON))}\n`); + if (!advisory.suppressed) { + process.stdout.write(`${JSON.stringify(allowOutput(NO_OP_HEARTBEAT_REASON))}\n`); + } return; } process.stdout.write(`${JSON.stringify(denyOutput(NO_OP_HEARTBEAT_REASON))}\n`); @@ -1097,22 +1358,64 @@ function runHook(env = process.env, input = readHookInput()) { const auditPath = extractAuditWritePath(targetPath, cwd); const budget = resolveBudget(env); const mode = resolveMode(env); + const writeBytes = inlineWriteBytes(input.tool_input); + const replacementBytes = toolName === "Edit" ? editReplacementBytes(input.tool_input) : null; + const pathSignature = writePathSignature(targetPath, cwd); + const tailMaxBytes = resolveTailMaxBytes(env); + const tailAllowance = resolveTailAllowance(env); + const zeroDispatchMaxBytes = resolveZeroDispatchMaxBytes(env); + const zeroDispatchWrites = resolveZeroDispatchWrites(env); const decision = withStateLock(file, () => { const state = normalizeState(readState(file), now, resolveFleetWaveGapMs(env)); + const dispatchCount = totalDispatches(state.dispatches); + let relief = null; if (mode === "enforce" && state.writesSinceDispatch >= budget) { - return { denied: true, writeCount: state.writesSinceDispatch, dispatchCount: totalDispatches(state.dispatches) }; + const tailAllowed = + toolName === "Edit" && + replacementBytes !== null && + replacementBytes <= tailMaxBytes && + tailAllowance > 0 && + state.tailWritesSinceDispatch < tailAllowance && + state.writtenPathSignatures.includes(pathSignature); + const zeroDispatchAllowed = + !tailAllowed && + dispatchCount === 0 && + state.writeCount < zeroDispatchWrites && + state.inlineWriteBytes + writeBytes < zeroDispatchMaxBytes; + if (!tailAllowed && !zeroDispatchAllowed) { + return { denied: true, writeCount: state.writesSinceDispatch, dispatchCount }; + } + relief = tailAllowed ? TAIL_ALLOW_AUDIT_EVENT : ZERO_DISPATCH_SOFTENED_AUDIT_EVENT; } state.writeCount += 1; state.writesSinceDispatch += 1; - const dispatchCount = totalDispatches(state.dispatches); + state.inlineWriteBytes += writeBytes; + if (!state.writtenPathSignatures.includes(pathSignature)) { + state.writtenPathSignatures.push(pathSignature); + } + if (relief === TAIL_ALLOW_AUDIT_EVENT) { + state.tailWritesSinceDispatch += 1; + } const multiple = budgetMultiple(state.writesSinceDispatch, budget); let candidate = null; if (multiple >= 1 && !state.advisedMultiples.includes(multiple)) { state.advisedMultiples.push(multiple); - candidate = { multiple, writeCount: state.writesSinceDispatch, dispatchEpoch: state.dispatchEpoch }; + if (!relief) { + candidate = { multiple, writeCount: state.writesSinceDispatch, dispatchEpoch: state.dispatchEpoch }; + } } writeState(file, state); - return { denied: false, writeCount: state.writesSinceDispatch, dispatchCount, candidate }; + return { + denied: false, + writeCount: state.writesSinceDispatch, + dispatchCount, + candidate, + relief, + dispatchEpoch: state.dispatchEpoch, + remainingTailSlots: tailAllowance - state.tailWritesSinceDispatch, + remainingZeroDispatchWrites: zeroDispatchWrites - state.writeCount, + remainingZeroDispatchBytes: zeroDispatchMaxBytes - state.inlineWriteBytes + }; }); if (decision.denied) { @@ -1137,29 +1440,80 @@ function runHook(env = process.env, input = readHookInput()) { } recordAuditEvent({ at: now, session: sessionId, event: "write", lane: MAIN_LANE, tool: toolName, ...(auditPath ? { path: auditPath } : {}) }, env); + if (decision.relief) { + const advisory = claimAdvisory( + file, + decision.relief, + stableAdvisorySignature( + decision.relief === TAIL_ALLOW_AUDIT_EVENT + ? [decision.dispatchEpoch, decision.remainingTailSlots] + : [decision.remainingZeroDispatchWrites, decision.remainingZeroDispatchBytes] + ), + now, + resolveFleetWaveGapMs(env) + ); + const auditEvent = { + at: now, + session: sessionId, + event: decision.relief, + lane: MAIN_LANE, + tool: toolName, + ...(auditPath ? { path: auditPath } : {}), + writeCount: decision.writeCount, + dispatchCount: decision.dispatchCount, + budget, + mode + }; + if (decision.relief === TAIL_ALLOW_AUDIT_EVENT) { + auditEvent.remainingTailSlots = decision.remainingTailSlots; + } else { + auditEvent.remainingZeroDispatchWrites = decision.remainingZeroDispatchWrites; + auditEvent.remainingZeroDispatchBytes = decision.remainingZeroDispatchBytes; + } + if (advisory.suppressed) { + auditEvent.suppressed = true; + } + recordAuditEvent(auditEvent, env); + if (!advisory.suppressed) { + const line = + decision.relief === TAIL_ALLOW_AUDIT_EVENT + ? buildTailAllowanceAdvisory(decision.remainingTailSlots) + : buildZeroDispatchAdvisory(decision.remainingZeroDispatchWrites, decision.remainingZeroDispatchBytes); + process.stdout.write(`${JSON.stringify(allowOutput(line, line))}\n`); + } + return; + } if (decision.candidate) { - withStateLock(file, () => { - const latest = normalizeState(readState(file), new Date().toISOString(), resolveFleetWaveGapMs(env)); - if (latest.dispatchEpoch === decision.candidate.dispatchEpoch && latest.advisedMultiples.includes(decision.candidate.multiple)) { - recordAuditEvent( - { - at: now, - session: sessionId, - event: "warn", - lane: MAIN_LANE, - tool: toolName, - ...(auditPath ? { path: auditPath } : {}), - writeCount: decision.writeCount, - dispatchCount: decision.dispatchCount, - budget, - mode - }, - env - ); - const advisory = buildAdvisoryLine(decision.writeCount, decision.dispatchCount); - process.stdout.write(`${JSON.stringify(allowOutput(advisory))}\n`); + const advisory = claimAdvisory( + file, + "write-budget", + stableAdvisorySignature([decision.candidate.multiple, budget, decision.candidate.dispatchEpoch]), + now, + resolveFleetWaveGapMs(env), + (state) => state.dispatchEpoch === decision.candidate.dispatchEpoch && state.advisedMultiples.includes(decision.candidate.multiple) + ); + if (!advisory.skipped) { + recordAuditEvent( + { + at: now, + session: sessionId, + event: "warn", + lane: MAIN_LANE, + tool: toolName, + ...(auditPath ? { path: auditPath } : {}), + writeCount: decision.writeCount, + dispatchCount: decision.dispatchCount, + budget, + mode, + ...(advisory.suppressed ? { suppressed: true } : {}) + }, + env + ); + if (!advisory.suppressed) { + const line = buildAdvisoryLine(decision.writeCount, decision.dispatchCount); + process.stdout.write(`${JSON.stringify(allowOutput(line))}\n`); } - }); + } } } @@ -1209,6 +1563,10 @@ export { resolveLockTimeoutMs, resolveMode, resolveStateDir, + resolveTailAllowance, + resolveTailMaxBytes, + resolveZeroDispatchMaxBytes, + resolveZeroDispatchWrites, sanitizeAuditPath, sanitizeAuditText, stateFile, diff --git a/tests/inline-delegation-guard.test.mjs b/tests/inline-delegation-guard.test.mjs index e57d4ae..0f98484 100644 --- a/tests/inline-delegation-guard.test.mjs +++ b/tests/inline-delegation-guard.test.mjs @@ -9,7 +9,11 @@ import { pathToFileURL } from "node:url"; import { DEFAULT_LOCK_TIMEOUT_MS, readAuditEvents, - resolveLockTimeoutMs + resolveLockTimeoutMs, + resolveTailAllowance, + resolveTailMaxBytes, + resolveZeroDispatchMaxBytes, + resolveZeroDispatchWrites } from "../plugins/fusion/scripts/inline-delegation-guard.mjs"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -91,7 +95,7 @@ async function runAsync(sandbox, payload, extraEnv = {}) { return runAsyncRaw(sandbox, payload, extraEnv); } -function writePayload(sandbox, { sessionId = "session-1", toolName = "Edit", filePath, cwd, agentId } = {}) { +function writePayload(sandbox, { sessionId = "session-1", toolName = "Edit", filePath, cwd, agentId, toolInput = {} } = {}) { const targetPath = filePath ?? path.join(sandbox.workDir, "file.txt"); const payload = { hook_event_name: "PreToolUse", @@ -99,7 +103,7 @@ function writePayload(sandbox, { sessionId = "session-1", toolName = "Edit", fil transcript_path: path.join(sandbox.root, "transcript.jsonl"), cwd: cwd ?? sandbox.workDir, tool_name: toolName, - tool_input: { file_path: targetPath } + tool_input: { file_path: targetPath, ...toolInput } }; if (agentId) { payload.agent_id = agentId; @@ -194,6 +198,7 @@ test("advisory compatibility mode never denies, even far past the budget", (t) = test("the default mode denies writes after the dispatch window budget and audits enforcement until an Agent dispatch", (t) => { const sandbox = makeSandbox(t); + run(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker" })); for (let i = 0; i < 5; i += 1) { const result = run(sandbox, writePayload(sandbox)); assert.notStrictEqual(JSON.parse(result.stdout || '{"hookSpecificOutput":{"permissionDecision":"allow"}}').hookSpecificOutput.permissionDecision, "deny"); @@ -219,7 +224,7 @@ test("the default mode denies writes after the dispatch window budget and audits tool: "Edit", path: "file.txt", writeCount: 5, - dispatchCount: 0, + dispatchCount: 1, budget: 5, mode: "enforce" }); @@ -230,15 +235,121 @@ test("the default mode denies writes after the dispatch window budget and audits assert.strictEqual(readState(sandbox, "session-1").writesSinceDispatch, 1); }); +test("a small Edit to a file written in the current window uses one tail slot", (t) => { + const sandbox = makeSandbox(t); + run(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker" })); + for (let index = 0; index < 5; index += 1) { + run(sandbox, writePayload(sandbox, { toolInput: { new_string: "base" } })); + } + + const tail = run(sandbox, writePayload(sandbox, { toolInput: { new_string: "finish" } })); + const output = JSON.parse(tail.stdout); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, "allow"); + assert.match(output.hookSpecificOutput.permissionDecisionReason, /2 tail slots remain/i); + assert.match(output.hookSpecificOutput.additionalContext, /2 tail slots remain/i); + assert.strictEqual(readState(sandbox, "session-1").tailWritesSinceDispatch, 1); + assert.deepStrictEqual( + readAuditRecords(sandbox).filter((record) => record.event === "tail-allowed").map((record) => record.remainingTailSlots), + [2] + ); +}); + +test("tail allowance denies a new path Write and an oversized Edit", (t) => { + for (const { toolName, filePath, toolInput } of [ + { toolName: "Write", filePath: "new-file.txt", toolInput: { content: "finish" } }, + { toolName: "Edit", filePath: "file.txt", toolInput: { new_string: "x".repeat(1025) } } + ]) { + const sandbox = makeSandbox(t); + run(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker" })); + for (let index = 0; index < 5; index += 1) { + run(sandbox, writePayload(sandbox, { toolInput: { new_string: "base" } })); + } + const denied = run(sandbox, writePayload(sandbox, { toolName, filePath: path.join(sandbox.workDir, filePath), toolInput })); + assert.strictEqual(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, "deny"); + assert.strictEqual(readAuditRecords(sandbox).filter((record) => record.event === "tail-allowed").length, 0); + } +}); + +test("tail allowance exhausts per window and resets after dispatch", (t) => { + const sandbox = makeSandbox(t); + run(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker" })); + for (let index = 0; index < 5; index += 1) { + run(sandbox, writePayload(sandbox, { toolInput: { new_string: "base" } })); + } + for (let index = 0; index < 3; index += 1) { + const tail = run(sandbox, writePayload(sandbox, { toolInput: { new_string: "finish" } })); + assert.strictEqual(JSON.parse(tail.stdout).hookSpecificOutput.permissionDecision, "allow"); + } + const denied = run(sandbox, writePayload(sandbox, { toolInput: { new_string: "finish" } })); + assert.strictEqual(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, "deny"); + + run(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker" })); + for (let index = 0; index < 5; index += 1) { + run(sandbox, writePayload(sandbox, { toolInput: { new_string: "base" } })); + } + const resetTail = run(sandbox, writePayload(sandbox, { toolInput: { new_string: "finish" } })); + assert.strictEqual(JSON.parse(resetTail.stdout).hookSpecificOutput.permissionDecision, "allow"); + assert.match(JSON.parse(resetTail.stdout).hookSpecificOutput.permissionDecisionReason, /2 tail slots remain/i); +}); + +test("zero dispatch sessions soften an exhausted budget within their write and byte bounds", (t) => { + const sandbox = makeSandbox(t); + for (let index = 0; index < 5; index += 1) { + run(sandbox, writePayload(sandbox, { toolInput: { new_string: "a" } })); + } + const softened = run(sandbox, writePayload(sandbox, { toolName: "Write", filePath: path.join(sandbox.workDir, "new-file.txt"), toolInput: { content: "a" } })); + const output = JSON.parse(softened.stdout); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, "allow"); + assert.match(output.hookSpecificOutput.permissionDecisionReason, /zero dispatch session/i); + assert.match(output.hookSpecificOutput.additionalContext, /zero dispatch session/i); + const audit = readAuditRecords(sandbox).filter((record) => record.event === "zero-dispatch-softened"); + assert.deepStrictEqual(audit.map((record) => [record.remainingZeroDispatchWrites, record.remainingZeroDispatchBytes]), [[4, 16378]]); +}); + +test("zero dispatch softening stops at its configured write and byte bounds", (t) => { + const writeSandbox = makeSandbox(t); + const writeEnv = { FUSION_INLINE_ZERO_DISPATCH_WRITES: "6" }; + for (let index = 0; index < 6; index += 1) { + run(writeSandbox, writePayload(writeSandbox, { toolName: "Write", filePath: path.join(writeSandbox.workDir, `write-${index}.txt`), toolInput: { content: "a" } }), writeEnv); + } + const writeDenied = run(writeSandbox, writePayload(writeSandbox, { toolName: "Write", filePath: path.join(writeSandbox.workDir, "write-7.txt"), toolInput: { content: "a" } }), writeEnv); + assert.strictEqual(JSON.parse(writeDenied.stdout).hookSpecificOutput.permissionDecision, "deny"); + + const byteSandbox = makeSandbox(t); + const byteEnv = { FUSION_INLINE_ZERO_DISPATCH_MAX_BYTES: "7" }; + for (let index = 0; index < 6; index += 1) { + run(byteSandbox, writePayload(byteSandbox, { toolName: "Write", filePath: path.join(byteSandbox.workDir, `byte-${index}.txt`), toolInput: { content: "a" } }), byteEnv); + } + const byteDenied = run(byteSandbox, writePayload(byteSandbox, { toolName: "Write", filePath: path.join(byteSandbox.workDir, "byte-7.txt"), toolInput: { content: "a" } }), byteEnv); + assert.strictEqual(JSON.parse(byteDenied.stdout).hookSpecificOutput.permissionDecision, "deny"); +}); + +test("tail allowance environment overrides apply and zero disables it", (t) => { + for (const { env, newString, expected } of [ + { env: { FUSION_INLINE_TAIL_MAX_BYTES: "1" }, newString: "xx", expected: "deny" }, + { env: { FUSION_INLINE_TAIL_ALLOWANCE: "0" }, newString: "x", expected: "deny" }, + { env: { FUSION_INLINE_TAIL_ALLOWANCE: "1" }, newString: "x", expected: "allow" } + ]) { + const sandbox = makeSandbox(t); + run(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker" }), env); + for (let index = 0; index < 5; index += 1) { + run(sandbox, writePayload(sandbox, { toolInput: { new_string: "base" } }), env); + } + const result = run(sandbox, writePayload(sandbox, { toolInput: { new_string: newString } }), env); + assert.strictEqual(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, expected); + } +}); + test("a PostToolUse-only dispatch recovers a missing launch and reopens an exhausted write budget", (t) => { const sandbox = makeSandbox(t); + const strictEnv = { FUSION_INLINE_ZERO_DISPATCH_WRITES: "0" }; for (let index = 0; index < 5; index += 1) { - run(sandbox, writePayload(sandbox)); + run(sandbox, writePayload(sandbox), strictEnv); } - const denied = run(sandbox, writePayload(sandbox)); + const denied = run(sandbox, writePayload(sandbox), strictEnv); assert.strictEqual(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, "deny"); - const recovered = runRaw(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker", description: "recover the missing launch" })); + const recovered = runRaw(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker", description: "recover the missing launch" }), strictEnv); assert.strictEqual(recovered.status, 0); assert.strictEqual(recovered.stdout, ""); assert.strictEqual(recovered.stderr, ""); @@ -264,7 +375,7 @@ test("a PostToolUse-only dispatch recovers a missing launch and reopens an exhau } ]); - const allowed = run(sandbox, writePayload(sandbox)); + const allowed = run(sandbox, writePayload(sandbox), strictEnv); assert.strictEqual(allowed.stdout, ""); assert.strictEqual(readState(sandbox, "session-1").writesSinceDispatch, 1); }); @@ -562,16 +673,17 @@ test("solo launch waves re-nudge at consecutive narrow streaks two and four", (t test("an Agent PreToolUse attempt does not reset the write window before dispatch succeeds", (t) => { const sandbox = makeSandbox(t); + const strictEnv = { FUSION_INLINE_ZERO_DISPATCH_WRITES: "0" }; for (let index = 0; index < 5; index += 1) { - run(sandbox, writePayload(sandbox)); + run(sandbox, writePayload(sandbox), strictEnv); } const attempted = dispatchPayload(sandbox, { subagentType: "fusion:fast-worker" }); attempted.hook_event_name = "PreToolUse"; - run(sandbox, attempted); + run(sandbox, attempted, strictEnv); assert.deepStrictEqual(readState(sandbox, "session-1").dispatches, {}); assert.strictEqual(readState(sandbox, "session-1").dispatchLog[0].phase, "launched"); - const denied = run(sandbox, writePayload(sandbox)); + const denied = run(sandbox, writePayload(sandbox), strictEnv); assert.strictEqual(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, "deny"); }); @@ -705,13 +817,14 @@ test("audit retains writes whose paths cannot be safely recorded", (t) => { test("enforcement audit records omit unsafe paths", (t) => { const sandbox = makeSandbox(t); + const strictEnv = { FUSION_INLINE_ZERO_DISPATCH_WRITES: "0" }; const pathSecret = `sk-${"z".repeat(40)}`; for (let index = 0; index < 4; index += 1) { - run(sandbox, writePayload(sandbox)); + run(sandbox, writePayload(sandbox), strictEnv); } const unsafeWrite = writePayload(sandbox, { filePath: path.join(sandbox.workDir, pathSecret) }); - run(sandbox, unsafeWrite); - run(sandbox, unsafeWrite); + run(sandbox, unsafeWrite, strictEnv); + run(sandbox, unsafeWrite, strictEnv); const enforcement = readAuditRecords(sandbox).filter((record) => record.event === "warn" || record.event === "deny"); assert.strictEqual(enforcement.length, 2); @@ -917,6 +1030,17 @@ test("an invalid FUSION_LOCK_TIMEOUT_MS falls back to the default", () => { assert.strictEqual(resolveLockTimeoutMs({ FUSION_LOCK_TIMEOUT_MS: "1.5" }), DEFAULT_LOCK_TIMEOUT_MS); }); +test("inline relief environment values accept overrides, preserve zero, and reject invalid values", () => { + assert.strictEqual(resolveTailMaxBytes({ FUSION_INLINE_TAIL_MAX_BYTES: "512" }), 512); + assert.strictEqual(resolveTailAllowance({ FUSION_INLINE_TAIL_ALLOWANCE: "0" }), 0); + assert.strictEqual(resolveZeroDispatchMaxBytes({ FUSION_INLINE_ZERO_DISPATCH_MAX_BYTES: "8192" }), 8192); + assert.strictEqual(resolveZeroDispatchWrites({ FUSION_INLINE_ZERO_DISPATCH_WRITES: "0" }), 0); + assert.strictEqual(resolveTailMaxBytes({ FUSION_INLINE_TAIL_MAX_BYTES: "invalid" }), 1024); + assert.strictEqual(resolveTailAllowance({ FUSION_INLINE_TAIL_ALLOWANCE: "-1" }), 3); + assert.strictEqual(resolveZeroDispatchMaxBytes({ FUSION_INLINE_ZERO_DISPATCH_MAX_BYTES: "1.5" }), 16384); + assert.strictEqual(resolveZeroDispatchWrites({ FUSION_INLINE_ZERO_DISPATCH_WRITES: "not-a-number" }), 10); +}); + test("FUSION_INLINE_WRITE_BUDGET overrides the default threshold", (t) => { const sandbox = makeSandbox(t); const extraEnv = { FUSION_INLINE_WRITE_BUDGET: "2" }; @@ -970,7 +1094,7 @@ test("audit appends after a malformed trailing line and readers skip only the da test("audit rotates by size without losing valid enforcement records", (t) => { const sandbox = makeSandbox(t); - const extraEnv = { FUSION_INLINE_GUARD_AUDIT_MAX_BYTES: "1", FUSION_INLINE_GUARD_AUDIT_MAX_FILES: "10" }; + const extraEnv = { FUSION_INLINE_GUARD_AUDIT_MAX_BYTES: "1", FUSION_INLINE_GUARD_AUDIT_MAX_FILES: "10", FUSION_INLINE_ZERO_DISPATCH_WRITES: "0" }; for (let index = 0; index < 6; index += 1) { run(sandbox, writePayload(sandbox), extraEnv); } @@ -1142,6 +1266,52 @@ test("advisory mode warns on no-op Bash true without denying when workers are in assert.strictEqual(warnings[0].mode, "advisory"); }); +test("advisory heartbeats emit once for unchanged state and audit suppressed repeats", (t) => { + const sandbox = makeSandbox(t); + seedInFlightWorker(sandbox); + const extraEnv = { ...workerStateEnv(sandbox), FUSION_INLINE_GUARD_MODE: "advisory" }; + + const first = run(sandbox, bashPayload(sandbox, { command: "true" }), extraEnv); + assert.strictEqual(JSON.parse(first.stdout).hookSpecificOutput.permissionDecision, "allow"); + const repeated = run(sandbox, bashPayload(sandbox, { command: "true" }), extraEnv); + assert.strictEqual(repeated.stdout, ""); + + const warnings = readAuditRecords(sandbox).filter((record) => record.event === "warn" && record.tool === "Bash"); + assert.strictEqual(warnings.length, 2); + assert.strictEqual(Object.hasOwn(warnings[0], "suppressed"), false); + assert.strictEqual(warnings[1].suppressed, true); +}); + +test("advisory heartbeat state changes re-arm its advisory", (t) => { + const sandbox = makeSandbox(t); + seedInFlightWorker(sandbox); + const extraEnv = { ...workerStateEnv(sandbox), FUSION_INLINE_GUARD_MODE: "advisory" }; + + assert.strictEqual(JSON.parse(run(sandbox, bashPayload(sandbox, { command: "true" }), extraEnv).stdout).hookSpecificOutput.permissionDecision, "allow"); + assert.strictEqual(run(sandbox, writePayload(sandbox), extraEnv).stdout, ""); + const rearmed = run(sandbox, bashPayload(sandbox, { command: "true" }), extraEnv); + assert.strictEqual(JSON.parse(rearmed.stdout).hookSpecificOutput.permissionDecision, "allow"); + + const warnings = readAuditRecords(sandbox).filter((record) => record.event === "warn" && record.tool === "Bash"); + assert.strictEqual(warnings.length, 2); + assert.ok(warnings.every((record) => !Object.hasOwn(record, "suppressed"))); +}); + +test("enforced heartbeats remain denials on repeated unchanged state", (t) => { + const sandbox = makeSandbox(t); + seedInFlightWorker(sandbox); + const extraEnv = workerStateEnv(sandbox); + + const first = run(sandbox, bashPayload(sandbox, { command: "true" }), extraEnv); + const repeated = run(sandbox, bashPayload(sandbox, { command: "true" }), extraEnv); + assert.strictEqual(JSON.parse(first.stdout).hookSpecificOutput.permissionDecision, "deny"); + assert.strictEqual(JSON.parse(repeated.stdout).hookSpecificOutput.permissionDecision, "deny"); + + const denials = readAuditRecords(sandbox).filter((record) => record.event === "deny" && record.tool === "Bash"); + assert.strictEqual(denials.length, 2); + assert.ok(denials.every((record) => !Object.hasOwn(record, "suppressed"))); +}); + test("no-op Bash deny audits an enforcement event for Bash on the main lane", (t) => { const sandbox = makeSandbox(t); seedInFlightWorker(sandbox); From 7aa6c6ce96ee11459eb02bce08dae85cfda54b3a Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:56 +0800 Subject: [PATCH 04/10] feat(fusion): grok token observer and monitor silence --- plugins/fusion/monitors/monitors.json | 6 + plugins/fusion/scripts/codex-jobs-monitor.mjs | 8 +- plugins/fusion/scripts/grok-jobs-observer.mjs | 192 ++++++++++++++++++ tests/grok-jobs-observer.test.mjs | 179 ++++++++++++++++ 4 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 plugins/fusion/scripts/grok-jobs-observer.mjs create mode 100644 tests/grok-jobs-observer.test.mjs diff --git a/plugins/fusion/monitors/monitors.json b/plugins/fusion/monitors/monitors.json index d338edd..88a8725 100644 --- a/plugins/fusion/monitors/monitors.json +++ b/plugins/fusion/monitors/monitors.json @@ -4,5 +4,11 @@ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/codex-jobs-monitor.mjs\"", "description": "Codex background job completions", "when": "always" + }, + { + "name": "grok-jobs-observer", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/grok-jobs-observer.mjs\"", + "description": "Grok token usage observations", + "when": "always" } ] diff --git a/plugins/fusion/scripts/codex-jobs-monitor.mjs b/plugins/fusion/scripts/codex-jobs-monitor.mjs index a271deb..d08aa4c 100644 --- a/plugins/fusion/scripts/codex-jobs-monitor.mjs +++ b/plugins/fusion/scripts/codex-jobs-monitor.mjs @@ -774,9 +774,6 @@ function installExitHandlers(timer) { async function main() { const repair = await loadCompanionRepair(); - if (!repair) { - safeWriteLine(REPAIR_UNAVAILABLE_LINE); - } const root = resolveStateRoot(); const cwd = process.cwd(); const workspaceRoot = resolveWorkspaceRoot(cwd); @@ -788,6 +785,7 @@ async function main() { const rolloutIndex = { files: [], scannedAt: 0 }; const repositoryCache = new Map(); const auditStates = new Map(); + let announceOnlyPending = !repair; let startupPending = true; pruneOldStateFiles(root, stateFile); @@ -797,6 +795,10 @@ async function main() { return; } const records = snapshot.records; + if (announceOnlyPending && records.length > 0) { + safeWriteLine(REPAIR_UNAVAILABLE_LINE); + announceOnlyPending = false; + } const liveIds = new Set(snapshot.recordIds); const terminalJobIds = new Set(records.filter(({ record }) => record?.id && TERMINAL_STATUSES.has(record.status)).map(({ record }) => record.id)); let dirty = false; diff --git a/plugins/fusion/scripts/grok-jobs-observer.mjs b/plugins/fusion/scripts/grok-jobs-observer.mjs new file mode 100644 index 0000000..c318e0d --- /dev/null +++ b/plugins/fusion/scripts/grok-jobs-observer.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { FILE_ENGINE_DESCRIPTORS, appendTokenUsageObservation, resolveFusionDataDir, tokenUsageSidecarPath } from "./fusion-stats.mjs"; + +const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); +const DEFAULT_POLL_INTERVAL_MS = 15000; +const DEFAULT_UNAVAILABLE_TTL_MS = 6 * 60 * 60 * 1000; +const INTERVAL_ENV = "GROK_JOBS_OBSERVER_INTERVAL_MS"; +const UNAVAILABLE_TTL_ENV = "GROK_JOBS_OBSERVER_UNAVAILABLE_TTL_MS"; +const STATE_SCHEMA_VERSION = 2; + +function resolvePollIntervalMs(env = process.env) { + const parsed = Number.parseInt(String(env[INTERVAL_ENV]), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_POLL_INTERVAL_MS; +} + +function resolveUnavailableTtlMs(env = process.env) { + const parsed = Number.parseInt(String(env[UNAVAILABLE_TTL_ENV]), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_UNAVAILABLE_TTL_MS; +} + +function nonNegativeInteger(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function tokenField(usage, names) { + for (const name of names) { + if (Object.hasOwn(usage, name)) { + return nonNegativeInteger(usage[name]); + } + } + return null; +} + +export function grokTokenUsageObservation(usage) { + if (!usage || typeof usage !== "object" || Array.isArray(usage)) { + return null; + } + return { + inputTokens: tokenField(usage, ["input_tokens", "inputTokens"]), + cachedInputTokens: tokenField(usage, ["cache_read_input_tokens", "cacheReadInputTokens", "cachedReadTokens", "cacheReadTokens"]), + outputTokens: tokenField(usage, ["output_tokens", "outputTokens"]), + reasoningOutputTokens: tokenField(usage, ["reasoning_tokens", "reasoningTokens"]), + totalTokens: tokenField(usage, ["total_tokens", "totalTokens"]) + }; +} + +export function grokJobsObserverStatePath(env = process.env) { + return path.join(resolveFusionDataDir(env), "state", "grok-jobs-observer.json"); +} + +function loadObserverState(file) { + try { + const state = JSON.parse(fs.readFileSync(file, "utf8")); + if (!Array.isArray(state?.observedJobIds)) { + return { observedJobIds: new Set(), unavailableObservedAt: new Map() }; + } + const unavailableObservedAt = state.schemaVersion === STATE_SCHEMA_VERSION && state.unavailableObservedAt && typeof state.unavailableObservedAt === "object" && !Array.isArray(state.unavailableObservedAt) + ? new Map(Object.entries(state.unavailableObservedAt).filter(([jobId, observedAt]) => typeof jobId === "string" && jobId && Number.isFinite(Date.parse(observedAt)))) + : new Map(); + return { observedJobIds: new Set(state.observedJobIds.filter((jobId) => typeof jobId === "string" && jobId)), unavailableObservedAt }; + } catch { + return { observedJobIds: new Set(), unavailableObservedAt: new Map() }; + } +} + +function saveObserverState(file, observedJobIds, unavailableObservedAt) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(path.dirname(file), 0o700); + } catch { + void 0; + } + const temporary = `${file}.${process.pid}.${Date.now()}.tmp`; + const state = { + schemaVersion: STATE_SCHEMA_VERSION, + engine: "grok", + observedJobIds: [...observedJobIds].sort(), + unavailableObservedAt: Object.fromEntries([...unavailableObservedAt].sort(([left], [right]) => left.localeCompare(right))), + updatedAt: new Date().toISOString() + }; + fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 }); + fs.chmodSync(temporary, 0o600); + fs.renameSync(temporary, file); + fs.chmodSync(file, 0o600); +} + +function hasUsageObject(record) { + return Boolean(record?.usage && typeof record.usage === "object" && !Array.isArray(record.usage)); +} + +function recordAgeMs(record, firstObservedAt, nowMs) { + const timestamp = [record?.finishedAt, record?.completedAt, record?.updatedAt, record?.createdAt, firstObservedAt] + .map((value) => Date.parse(value)) + .find(Number.isFinite); + return Number.isFinite(timestamp) && Number.isFinite(nowMs) ? nowMs - timestamp : 0; +} + +function terminalRecords(env) { + const descriptor = FILE_ENGINE_DESCRIPTORS.grok; + const stateRoot = descriptor.defaultStateRoot(env); + return descriptor + .enumerateJobs(stateRoot) + .filter((record) => TERMINAL_STATUSES.has(record?.status) && typeof record?.id === "string" && record.id && typeof record?.cwd === "string" && record.cwd) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +export function observeGrokJobs({ env = process.env, now = () => new Date().toISOString() } = {}) { + const stateFile = grokJobsObserverStatePath(env); + const { observedJobIds, unavailableObservedAt } = loadObserverState(stateFile); + const unavailableTtlMs = resolveUnavailableTtlMs(env); + let changed = false; + let observations = 0; + for (const record of terminalRecords(env)) { + if (observedJobIds.has(record.id)) { + continue; + } + const workspaceRoot = path.resolve(record.cwd); + const available = hasUsageObject(record); + const observedAt = now(); + const appended = appendTokenUsageObservation(tokenUsageSidecarPath(workspaceRoot, env), { + schemaVersion: 1, + jobId: record.id, + engine: "grok", + workspaceRoot, + availability: available ? "available" : "unavailable", + tokenUsage: available ? grokTokenUsageObservation(record.usage) : null, + source: "grok-job-record", + observedAt + }); + if (!appended) { + continue; + } + observations += 1; + if (available) { + const becameObserved = !observedJobIds.has(record.id); + observedJobIds.add(record.id); + if (unavailableObservedAt.delete(record.id) || becameObserved) { + changed = true; + } + continue; + } + const firstObservedAt = unavailableObservedAt.get(record.id) ?? observedAt; + if (!unavailableObservedAt.has(record.id)) { + unavailableObservedAt.set(record.id, firstObservedAt); + changed = true; + } + if (recordAgeMs(record, firstObservedAt, Date.parse(observedAt)) > unavailableTtlMs) { + observedJobIds.add(record.id); + unavailableObservedAt.delete(record.id); + changed = true; + } + } + if (changed) { + saveObserverState(stateFile, observedJobIds, unavailableObservedAt); + } + return observations; +} + +function observeGrokJobsSafely() { + try { + return observeGrokJobs(); + } catch { + return 0; + } +} + +function installExitHandlers(timer) { + const shutdown = () => { + clearInterval(timer); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + process.on("SIGHUP", shutdown); +} + +function main() { + observeGrokJobsSafely(); + const timer = setInterval(() => { + observeGrokJobsSafely(); + }, resolvePollIntervalMs()); + installExitHandlers(timer); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/tests/grok-jobs-observer.test.mjs b/tests/grok-jobs-observer.test.mjs new file mode 100644 index 0000000..264e0b5 --- /dev/null +++ b/tests/grok-jobs-observer.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +import { tokenUsageSidecarPath } from "../plugins/fusion/scripts/fusion-stats.mjs"; +import { grokJobsObserverStatePath, observeGrokJobs } from "../plugins/fusion/scripts/grok-jobs-observer.mjs"; + +const observerScript = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "plugins", "fusion", "scripts", "grok-jobs-observer.mjs"); + +function sandbox(t) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "grok-jobs-observer-test-"))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function grokWorkspaceSlug(cwd) { + const absolute = path.resolve(cwd); + return `${path.basename(absolute)}-${createHash("sha256").update(absolute).digest("hex").slice(0, 16)}`; +} + +function writeGrokJob(dataDir, cwd, id, fields) { + const jobsDir = path.join(dataDir, "state", grokWorkspaceSlug(cwd), "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + fs.writeFileSync(path.join(jobsDir, `${id}.json`), `${JSON.stringify({ id, cwd, ...fields })}\n`); +} + +function readJsonLines(file) { + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); +} + +test("reobserves unavailable terminal jobs until usage becomes available", (t) => { + const root = sandbox(t); + const workspaceRoot = path.join(root, "workspace"); + const grokData = path.join(root, "grok-data"); + const fusionData = path.join(root, "fusion-data"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + const env = { GROK_COMPANION_DATA: grokData, FUSION_DATA_DIR: fusionData }; + writeGrokJob(grokData, workspaceRoot, "grok-available", { + status: "done", + usage: { + input_tokens: 120, + cache_read_input_tokens: 40, + output_tokens: 30, + reasoning_tokens: 12, + total_tokens: 150 + } + }); + writeGrokJob(grokData, workspaceRoot, "grok-unavailable", { status: "error" }); + writeGrokJob(grokData, workspaceRoot, "grok-running", { + status: "running", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1, reasoning_tokens: 0, total_tokens: 2 } + }); + + assert.strictEqual(observeGrokJobs({ env, now: () => "2026-07-22T00:00:00.000Z" }), 2); + const observations = readJsonLines(tokenUsageSidecarPath(workspaceRoot, env)); + assert.deepStrictEqual(observations, [ + { + schemaVersion: 1, + jobId: "grok-available", + engine: "grok", + workspaceRoot, + availability: "available", + tokenUsage: { inputTokens: 120, cachedInputTokens: 40, outputTokens: 30, reasoningOutputTokens: 12, totalTokens: 150 }, + source: "grok-job-record", + observedAt: "2026-07-22T00:00:00.000Z" + }, + { + schemaVersion: 1, + jobId: "grok-unavailable", + engine: "grok", + workspaceRoot, + availability: "unavailable", + tokenUsage: null, + source: "grok-job-record", + observedAt: "2026-07-22T00:00:00.000Z" + } + ]); + const observerState = JSON.parse(fs.readFileSync(grokJobsObserverStatePath(env), "utf8")); + assert.strictEqual(observerState.schemaVersion, 2); + assert.deepStrictEqual(observerState.observedJobIds, ["grok-available"]); + assert.deepStrictEqual(observerState.unavailableObservedAt, { "grok-unavailable": "2026-07-22T00:00:00.000Z" }); + assert.strictEqual(observeGrokJobs({ env, now: () => "2026-07-22T00:01:00.000Z" }), 1); + assert.deepStrictEqual(readJsonLines(tokenUsageSidecarPath(workspaceRoot, env)), observations); + + writeGrokJob(grokData, workspaceRoot, "grok-unavailable", { + status: "error", + usage: { input_tokens: 90, cache_read_input_tokens: 20, output_tokens: 10, reasoning_tokens: 4, total_tokens: 120 } + }); + assert.strictEqual(observeGrokJobs({ env, now: () => "2026-07-22T00:02:00.000Z" }), 1); + assert.deepStrictEqual(readJsonLines(tokenUsageSidecarPath(workspaceRoot, env)), [ + ...observations, + { + schemaVersion: 1, + jobId: "grok-unavailable", + engine: "grok", + workspaceRoot, + availability: "available", + tokenUsage: { inputTokens: 90, cachedInputTokens: 20, outputTokens: 10, reasoningOutputTokens: 4, totalTokens: 120 }, + source: "grok-job-record", + observedAt: "2026-07-22T00:02:00.000Z" + } + ]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(grokJobsObserverStatePath(env), "utf8")).observedJobIds, ["grok-available", "grok-unavailable"]); + assert.strictEqual(observeGrokJobs({ env, now: () => "2026-07-22T00:03:00.000Z" }), 0); +}); + +test("marks an unavailable terminal job only after its observation TTL", (t) => { + const root = sandbox(t); + const workspaceRoot = path.join(root, "workspace"); + const grokData = path.join(root, "grok-data"); + const fusionData = path.join(root, "fusion-data"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + const env = { GROK_COMPANION_DATA: grokData, FUSION_DATA_DIR: fusionData, GROK_JOBS_OBSERVER_UNAVAILABLE_TTL_MS: "1000" }; + writeGrokJob(grokData, workspaceRoot, "grok-late-usage", { status: "done" }); + + assert.strictEqual(observeGrokJobs({ env, now: () => "2026-07-22T00:00:00.000Z" }), 1); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(grokJobsObserverStatePath(env), "utf8")).observedJobIds, []); + assert.strictEqual(observeGrokJobs({ env, now: () => "2026-07-22T00:00:01.001Z" }), 1); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(grokJobsObserverStatePath(env), "utf8")).observedJobIds, ["grok-late-usage"]); + assert.strictEqual(readJsonLines(tokenUsageSidecarPath(workspaceRoot, env)).length, 1); +}); + +test("the observer emits no stdout when its state has no new terminal jobs", (t) => { + const root = sandbox(t); + const workspaceRoot = path.join(root, "workspace"); + const grokData = path.join(root, "grok-data"); + const fusionData = path.join(root, "fusion-data"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + const env = { GROK_COMPANION_DATA: grokData, FUSION_DATA_DIR: fusionData }; + writeGrokJob(grokData, workspaceRoot, "grok-observed", { + status: "done", + usage: { inputTokens: 10, cacheReadTokens: 2, outputTokens: 3, reasoningTokens: 1, totalTokens: 13 } + }); + assert.strictEqual(observeGrokJobs({ env }), 1); + + const result = spawnSync(process.execPath, [observerScript], { + cwd: workspaceRoot, + encoding: "utf8", + env: { ...process.env, ...env, GROK_JOBS_OBSERVER_INTERVAL_MS: "1000" }, + timeout: 400, + killSignal: "SIGTERM" + }); + + assert.strictEqual(result.stdout, ""); +}); + +test("the observer monitor ignores poll IO failures", (t) => { + const root = sandbox(t); + const workspaceRoot = path.join(root, "workspace"); + const grokData = path.join(root, "grok-data"); + const fusionData = path.join(root, "fusion-data"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.writeFileSync(fusionData, "not a directory\n"); + writeGrokJob(grokData, workspaceRoot, "grok-io-failure", { + status: "done", + usage: { input_tokens: 10, cache_read_input_tokens: 2, output_tokens: 3, reasoning_tokens: 1, total_tokens: 13 } + }); + + const result = spawnSync(process.execPath, [observerScript], { + cwd: workspaceRoot, + encoding: "utf8", + env: { ...process.env, GROK_COMPANION_DATA: grokData, FUSION_DATA_DIR: fusionData, GROK_JOBS_OBSERVER_INTERVAL_MS: "25" }, + timeout: 200, + killSignal: "SIGTERM" + }); + + assert.strictEqual(result.stdout, ""); + assert.strictEqual(result.stderr, ""); + assert.strictEqual(result.status, 0); +}); From 2a2b699742886ed828885802975db2fa3c9d455c Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:56 +0800 Subject: [PATCH 05/10] feat(codex): sol foreground warning, timeout resume surfacing, repository top level --- plugins/codex/scripts/codex-companion.mjs | 53 +++++++++++- plugins/codex/scripts/lib/state.mjs | 1 + tests/codex-companion.test.mjs | 100 +++++++++++++++++++++- 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 42afeb2..f199a0a 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -97,6 +97,7 @@ const TRANSPORT_MAX_AGE_MS = 60 * 60 * 1000; const RECORD_ACCEPTANCE_JOB_ID_PATTERN = /^[a-f0-9]{32}$/; const RECORD_ACCEPTANCE_VALUES = new Set(["accepted", "rejected", "unverified"]); const RECORD_ACCEPTANCE_SOURCES = new Set(["collector", "main-loop", "stats"]); +const SOL_FOREGROUND_WRITE_WARNING = "warning: sol p90 wall clock exceeds the 600s foreground cap. Split the brief or name gpt-5.6-terra."; let activeCommandArgv = null; class CompanionError extends Error { @@ -966,8 +967,48 @@ function currentProcessIdentity() { return identity; } +function isSolForegroundWriteTask(record) { + return ( + record.jobClass === "task" && + record.delivery === "foreground" && + record.mode === "write" && + typeof record.resolvedModel === "string" && + record.resolvedModel.toLowerCase().includes("sol") + ); +} + +function shellArgument(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function timeoutResumeCommand(record, threadId) { + return `${shellArgument(process.execPath)} ${shellArgument(SELF_PATH)} task --resume ${shellArgument(threadId)} --cwd ${shellArgument(record.cwd)}`; +} + +function appendResumeFooter(text, footer) { + const value = typeof text === "string" ? text.trimEnd() : ""; + return value.split("\n").includes(footer) ? value : value ? `${value}\n\n${footer}` : footer; +} + +function timeoutResumePatch(record, patch) { + const failureKind = patch.failureKind ?? record.failureKind; + const threadId = patch.threadId ?? record.threadId ?? record.request?.resumeThreadId; + if (failureKind !== "timeout" || typeof threadId !== "string" || !threadId.trim()) { + return patch; + } + const resumeCommand = timeoutResumeCommand(record, threadId); + const footer = `Resume Codex job ${record.id}: ${resumeCommand}`; + const resultText = Object.hasOwn(patch, "resultText") ? patch.resultText : record.resultText; + const partialResultText = Object.hasOwn(patch, "partialResultText") ? patch.partialResultText : record.partialResultText; + if (typeof resultText === "string" && resultText.trim()) { + return { ...patch, resumable: true, resumeCommand, resultText: appendResumeFooter(resultText, footer) }; + } + return { ...patch, resumable: true, resumeCommand, partialResultText: appendResumeFooter(partialResultText, footer) }; +} + function finishJob(file, patch, env = process.env) { - const record = finishJobRecordFile(file, patch); + const existing = readJobRecordFile(file); + const record = finishJobRecordFile(file, existing ? timeoutResumePatch(existing, patch) : patch); pruneJobState(resolveDataDir(env), { claudeSessionId: record.claudeSessionId, protectedJobFiles: [file], resumeWorkspaceJobFiles: [file] }); return record; } @@ -1407,9 +1448,14 @@ async function executeRecord(found) { const structured = structuredOutputOutcome(record.request ?? {}, outcome); const resolvedModel = outcome.resolvedModel ?? record.resolvedModel; const modelDrift = taskModelDrift(record, prompt, resolvedModel); + const completedRecord = { ...record, resolvedModel }; + const diagnostics = outcome.diagnostics.map((diagnostic) => ({ ...diagnostic, message: redactDiagnostic(diagnostic.message) })); + if (isSolForegroundWriteTask(completedRecord)) { + diagnostics.push({ type: "warning", message: SOL_FOREGROUND_WRITE_WARNING }); + } return finishJob(found.file, { cumulativeTokenUsage: outcome.cumulativeTokenUsage, - diagnostics: outcome.diagnostics.map((diagnostic) => ({ ...diagnostic, message: redactDiagnostic(diagnostic.message) })), + diagnostics, errorMessage: outcome.errorMessage ? redactDiagnostic(outcome.errorMessage) : null, errorTail: outcome.status === "done" ? null : errorTail || outcome.errorMessage, eventsTruncated: outcome.eventsTruncated, @@ -1626,6 +1672,9 @@ async function dispatchJob(found, asJson) { return; } const completed = await executeRecord(found); + if (isSolForegroundWriteTask(completed)) { + process.stderr.write(`${SOL_FOREGROUND_WRITE_WARNING}\n`); + } renderRecord(completed, asJson); collectRenderedJob(found.file, completed); if (completed.status !== "done") { diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index a9c47f0..6795a69 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -670,6 +670,7 @@ export function createJobRecord(fields) { deliveryCollectedAt: fields.deliveryCollectedAt ?? null, cwd, workspaceRoot, + repositoryTopLevel: gitValue(cwd, ["rev-parse", "--show-toplevel"]), repositoryIdentity: identity, repositoryKey: fields.repositoryKey ?? createHash("sha256").update(identity).digest("hex").slice(0, 16), sessionId, diff --git a/tests/codex-companion.test.mjs b/tests/codex-companion.test.mjs index faa4ef0..8b251d2 100644 --- a/tests/codex-companion.test.mjs +++ b/tests/codex-companion.test.mjs @@ -72,6 +72,21 @@ test("task header parsing captures model and effort only from a pipe-separated h assert.deepEqual(parseTaskHeader("lane: codex | model: gpt-5.6-terra"), { headerModel: "gpt-5.6-terra", headerEffort: null }); }); +test("job records capture the repository top level without requiring a Git repository", (t) => { + const sandbox = makeSandbox(t); + const repository = path.join(sandbox.root, "repository"); + const nonRepository = path.join(sandbox.root, "non-repository"); + fs.mkdirSync(repository); + fs.mkdirSync(nonRepository); + execFileSync("git", ["init", "--quiet"], { cwd: repository }); + + const repositoryRecord = createJobRecord({ cwd: repository, id: "repository-top-level" }); + const nonRepositoryRecord = createJobRecord({ cwd: nonRepository, id: "non-repository-top-level" }); + + assert.equal(repositoryRecord.repositoryTopLevel, execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repository, encoding: "utf8" }).trim()); + assert.equal(nonRepositoryRecord.repositoryTopLevel, null); +}); + test("running record reconciliation waits for the pidless grace window", () => { const createdAt = new Date(Date.now() - 1000).toISOString(); assert.equal(runningRecordNeedsReconciliation({ createdAt, pid: null, status: "running" }, { CODEX_COMPANION_PIDLESS_RUNNING_GRACE_MS: "0" }), true); @@ -220,6 +235,62 @@ test("task stays foreground by default and persists the complete terminal record assert.equal(fs.statSync(path.dirname(entry.file)).mode & 0o777, 0o700); }); +test("sol foreground write tasks add one diagnostic warning without changing the prompt", (t) => { + const sandbox = makeSandbox(t); + const result = runCompanion(["task", "--write", "--model", "requested-model", "implement the sol-safe change"], { + cwd: sandbox.workDir, + env: envFor(sandbox, { + CODEX_HOME: path.join(sandbox.root, "codex-home"), + FAKE_CODEX_MODE: "rollout-completed", + FAKE_CODEX_RESOLVED_MODEL: "gpt-5.6-sol" + }) + }); + const warning = "warning: sol p90 wall clock exceeds the 600s foreground cap. Split the brief or name gpt-5.6-terra."; + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, `${warning}\n`); + assert.equal(fs.readFileSync(sandbox.stdinFile, "utf8").trim(), "implement the sol-safe change"); + const [record] = jobRecords(sandbox); + assert.equal(record.diagnostics.filter((diagnostic) => diagnostic.type === "warning" && diagnostic.message === warning).length, 1); +}); + +test("sol warning is limited to foreground write tasks", async (t) => { + const warning = "warning: sol p90 wall clock exceeds the 600s foreground cap. Split the brief or name gpt-5.6-terra."; + const cases = [ + { args: ["task", "--write", "use terra"], model: "gpt-5.6-terra" }, + { args: ["task", "use sol in consult mode"], model: "gpt-5.6-sol" } + ]; + for (const entry of cases) { + const sandbox = makeSandbox(t); + const result = runCompanion(entry.args, { + cwd: sandbox.workDir, + env: envFor(sandbox, { + CODEX_HOME: path.join(sandbox.root, "codex-home"), + FAKE_CODEX_MODE: "rollout-completed", + FAKE_CODEX_RESOLVED_MODEL: entry.model + }) + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.equal(jobRecords(sandbox)[0].diagnostics.some((diagnostic) => diagnostic.message === warning), false); + } + + const background = makeSandbox(t); + const env = envFor(background, { + CODEX_HOME: path.join(background.root, "codex-home"), + FAKE_CODEX_MODE: "rollout-completed", + FAKE_CODEX_RESOLVED_MODEL: "gpt-5.6-sol" + }); + const launched = runCompanion(["task", "--background", "--write", "use sol in the background"], { cwd: background.workDir, env }); + assert.equal(launched.status, 0, launched.stderr); + assert.equal(launched.stderr, ""); + const completed = await waitFor(() => { + const [record] = jobRecords(background); + return record?.status === "done" ? record : null; + }); + assert.equal(completed.diagnostics.some((diagnostic) => diagnostic.message === warning), false); +}); + test("task resolves an output schema, forwards it, and records parsed structured output", (t) => { const sandbox = makeSandbox(t); const schemaDirectory = path.join(sandbox.workDir, "schemas"); @@ -955,13 +1026,40 @@ test("foreground timeout persists recovered partial delivery and incomplete cumu assert.equal(record.status, "error"); assert.equal(record.failureKind, "timeout"); assert.equal(record.resultText, null); - assert.equal(record.partialResultText, "Recovered partial Codex output."); + const resumeCommand = `'${process.execPath}' '${companion}' task --resume 'thread-123' --cwd '${fs.realpathSync(sandbox.workDir)}'`; + const footer = `Resume Codex job ${record.id}: ${resumeCommand}`; + assert.equal(record.partialResultText, `Recovered partial Codex output.\n\n${footer}`); + assert.equal(record.resumable, true); + assert.equal(record.resumeCommand, resumeCommand); + assert.equal(record.partialResultText.split(footer).length - 1, 1); + const rendered = runCompanion(["result", record.id], { cwd: sandbox.workDir, env: envFor(sandbox) }); + assert.equal(rendered.status, 1); + assert.equal(rendered.stdout.split(footer).length - 1, 1); assert.equal(record.tokenUsageAvailability, "partial"); assert.equal(record.usageIsIncomplete, true); assert.equal(record.resolvedModel, "gpt-resolved"); assert.equal(record.resolvedEffort, "xhigh"); }); +test("timeout jobs without a thread do not advertise resumability", (t) => { + const sandbox = makeSandbox(t); + const result = runCompanion(["task", "--json", "timeout before the thread starts"], { + cwd: sandbox.workDir, + env: envFor(sandbox, { + CODEX_COMPANION_TIMEOUT_MS: "50", + FAKE_CODEX_DELAY_MS: "500" + }) + }); + + assert.equal(result.status, 1, result.stderr); + const record = JSON.parse(result.stdout); + assert.equal(record.status, "error"); + assert.equal(record.failureKind, "timeout"); + assert.equal(record.threadId, null); + assert.equal(Object.hasOwn(record, "resumable"), false); + assert.equal(Object.hasOwn(record, "resumeCommand"), false); +}); + test("history lists canonical jobs across workspaces with local thread and delivery metadata", (t) => { const sandbox = makeSandbox(t); const sibling = path.join(sandbox.root, "sibling"); From 7d6bfc4b49352a8f118a61d07f30a5ec6748ea07 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:56 +0800 Subject: [PATCH 06/10] feat(grok): permission denial detail and repository top level --- plugins/grok/scripts/grok-companion.mjs | 132 +++++++++++++-------- plugins/grok/scripts/lib/grok-exec.mjs | 38 +++++- tests/grok-companion.test.mjs | 147 ++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 51 deletions(-) create mode 100644 tests/grok-companion.test.mjs diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index 3af9344..151f896 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -12,6 +12,7 @@ import { parseArgs, parseRawArgs, rawBooleanOptionRequested } from "./lib/args.m import { buildGrokArgs, formatBlockedPermissionCall, + formatDeniedToolDetail, normalizeGrokSessionId, resolveGrokBin, resolveGrokCapabilities, @@ -1012,7 +1013,16 @@ function isPermissionCancelled(result) { } function permissionFailureMessage(result) { - return PERMISSION_FAILURE_MESSAGE + formatBlockedPermissionCall(result?.blockedPermissionCall ?? null); + const blocked = result?.blockedPermissionCall ?? null; + const hasBlockedTool = Boolean(blocked && typeof blocked.tool === "string" && blocked.tool.trim()); + const deniedToolDetail = formatDeniedToolDetail(result?.deniedToolFromStderr ?? null); + if (hasBlockedTool) { + return PERMISSION_FAILURE_MESSAGE + formatBlockedPermissionCall(blocked) + deniedToolDetail; + } + if (deniedToolDetail) { + return PERMISSION_FAILURE_MESSAGE + deniedToolDetail; + } + return PERMISSION_FAILURE_MESSAGE + formatBlockedPermissionCall(null); } function grokFailureMessage(result, timeoutMs, failureKind) { @@ -1543,33 +1553,36 @@ async function handleTask(argv, transport = {}) { const jobId = generateJobId(); const briefFile = briefPath(dataDir, cwd, jobId); const jobFile = jobFilePath(dataDir, cwd, jobId); - const record = createJobRecord({ - id: jobId, - pid: background ? null : process.pid, - mode, - cwd, - briefFile, - background, - delivery: background ? backgroundDelivery() : "foreground", - claudeSessionId, - jobClass: "task", - role, - request: { - model: options.model ?? null, - effort: options.effort ?? null, - maxTurns, - web: Boolean(options.web), - memory, - sandboxProfile: sandboxProfileForMode(mode), - resumeSessionId, - resumeSourceJobId: resumeSource?.id ?? null, - resumeReason, - continuityPolicy: selectedContinuityPolicy, + const record = { + ...createJobRecord({ + id: jobId, + pid: background ? null : process.pid, + mode, + cwd, + briefFile, + background, + delivery: background ? backgroundDelivery() : "foreground", + claudeSessionId, + jobClass: "task", role, - outputJson: Boolean(options.json), - ingress: transport.ingress ?? "argv" - } - }); + request: { + model: options.model ?? null, + effort: options.effort ?? null, + maxTurns, + web: Boolean(options.web), + memory, + sandboxProfile: sandboxProfileForMode(mode), + resumeSessionId, + resumeSourceJobId: resumeSource?.id ?? null, + resumeReason, + continuityPolicy: selectedContinuityPolicy, + role, + outputJson: Boolean(options.json), + ingress: transport.ingress ?? "argv" + } + }), + repositoryTopLevel: resolveRepositoryTopLevel(cwd) + }; createJobRecordFile(jobFile, record); try { writeBrief(dataDir, cwd, jobId, prompt); @@ -1779,6 +1792,22 @@ function ensureGitRepository(cwd) { } } +function resolveRepositoryTopLevel(cwd) { + try { + const result = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + if (result.error || result.status !== 0) { + return null; + } + const value = String(result.stdout ?? "").trim(); + return value || null; + } catch { + return null; + } +} + function collectReviewTarget(cwd, base) { if (base) { const range = runGit(["diff", `${base}...HEAD`], cwd); @@ -1940,24 +1969,27 @@ async function handleReview(argv, transport = {}) { const jobId = generateJobId(); const briefFile = briefPath(dataDir, cwd, jobId); const jobFile = jobFilePath(dataDir, cwd, jobId); - const record = createJobRecord({ - id: jobId, - pid: background ? null : process.pid, - mode: "consult", - cwd, - briefFile, - background, - delivery: background ? backgroundDelivery() : "foreground", - claudeSessionId: currentClaudeSessionId(), - jobClass: "review", - request: { - reviewTargetLabel: target.label, - memory: false, - sandboxProfile: sandboxProfileForMode("consult"), - outputJson: Boolean(options.json), - ingress: transport.ingress ?? "argv" - } - }); + const record = { + ...createJobRecord({ + id: jobId, + pid: background ? null : process.pid, + mode: "consult", + cwd, + briefFile, + background, + delivery: background ? backgroundDelivery() : "foreground", + claudeSessionId: currentClaudeSessionId(), + jobClass: "review", + request: { + reviewTargetLabel: target.label, + memory: false, + sandboxProfile: sandboxProfileForMode("consult"), + outputJson: Boolean(options.json), + ingress: transport.ingress ?? "argv" + } + }), + repositoryTopLevel: resolveRepositoryTopLevel(cwd) + }; createJobRecordFile(jobFile, record); try { writeBrief(dataDir, cwd, jobId, prompt); @@ -2884,9 +2916,8 @@ async function handleStopGate() { const jobId = generateJobId(); const briefFile = briefPath(dataDir, cwd, jobId); const jobFile = jobFilePath(dataDir, cwd, jobId); - createJobRecordFile( - jobFile, - createJobRecord({ + createJobRecordFile(jobFile, { + ...createJobRecord({ id: jobId, pid: process.pid, mode: "consult", @@ -2900,8 +2931,9 @@ async function handleStopGate() { memory: false, sandboxProfile: sandboxProfileForMode("consult") } - }) - ); + }), + repositoryTopLevel: resolveRepositoryTopLevel(cwd) + }); try { writeBrief(dataDir, cwd, jobId, prompt); } catch (error) { diff --git a/plugins/grok/scripts/lib/grok-exec.mjs b/plugins/grok/scripts/lib/grok-exec.mjs index eb801c4..84a4589 100644 --- a/plugins/grok/scripts/lib/grok-exec.mjs +++ b/plugins/grok/scripts/lib/grok-exec.mjs @@ -408,6 +408,40 @@ function maxTurnsReachedError(stderr) { return null; } +const DENIED_TOOL_STDERR_PATTERNS = [ + /\b(?:tool|rule)\s+[`'"]?([A-Za-z_][\w./:-]*(?:\([^)\r\n]*\))?)[`'"]?\s+(?:(?:is|was)\s+)?(?:not allowed|denied|disallowed|blocked|rejected)/i, + /\b(?:denied|disallowed|blocked|rejected)\s+(?:tool|rule)\s*[:=]?\s*[`'"]?([A-Za-z_][\w./:-]*(?:\([^)\r\n]*\))?)[`'"]?/i, + /\bpermission\s+denied(?:\s+(?:for|to))?\s+(?:tool\s+)?[`'"]?([A-Za-z_][\w./:-]*(?:\([^)\r\n]*\))?)[`'"]?/i, + /\bnot\s+allowed\s+to\s+(?:use\s+)?(?:tool\s+)?[`'"]?([A-Za-z_][\w./:-]*(?:\([^)\r\n]*\))?)[`'"]?/i, + /\b(?:deny|denied)\s+(?:matched\s+)?rule\s*[:=]?\s*[`'"]?([A-Za-z_][\w./()*: -]*)[`'"]?/i +]; +const DENIED_TOOL_STDERR_NOISE = /^(?:operation|os|error|permission|access|for|to|the|a|an|by)$/i; + +export function extractDeniedToolFromStderr(stderr) { + const lines = String(stderr ?? "").split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index].trim(); + if (!line) { + continue; + } + for (const pattern of DENIED_TOOL_STDERR_PATTERNS) { + const match = line.match(pattern); + const candidate = match?.[1]?.trim(); + if (candidate && !DENIED_TOOL_STDERR_NOISE.test(candidate)) { + return candidate; + } + } + } + return null; +} + +export function formatDeniedToolDetail(toolName) { + if (typeof toolName !== "string" || !toolName.trim()) { + return ""; + } + return `; denied tool: ${toolName.trim()}`; +} + function iterJsonObjects(stdout) { const trimmed = String(stdout ?? "").trim(); if (!trimmed) { @@ -2048,6 +2082,7 @@ export function runGrok(options) { const stopReason = validEnvelope ? parsed.stopReason : null; const blockedPermissionCall = stopReason === "Cancelled" ? extractBlockedPermissionCall(stdout, validEnvelope ? parsed : null) : null; + const deniedToolFromStderr = extractDeniedToolFromStderr(stderr); const structuredOutput = parsed && Object.hasOwn(parsed, "structuredOutput") ? parsed.structuredOutput : undefined; const structuredOutputError = parsed && Object.hasOwn(parsed, "structuredOutputError") ? parsed.structuredOutputError @@ -2081,7 +2116,8 @@ export function runGrok(options) { stdoutTail: stdoutTail(stdoutTailBuffer), cancelledByCompanion, promptTransportError: Boolean(promptTransportError), - blockedPermissionCall + blockedPermissionCall, + deniedToolFromStderr }); }); diff --git a/tests/grok-companion.test.mjs b/tests/grok-companion.test.mjs new file mode 100644 index 0000000..0ebbacc --- /dev/null +++ b/tests/grok-companion.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { + envFor, + jobRecords, + makeSandbox, + repoRoot, + runCompanion +} from "./lib/companion-harness.mjs"; +import { gitIsolation, initCleanRepo } from "./lib/git-fixture.mjs"; + +const grokExecPath = path.join(repoRoot, "plugins", "grok", "scripts", "lib", "grok-exec.mjs"); +const { extractDeniedToolFromStderr, formatDeniedToolDetail } = await import(grokExecPath); + +function writePermissionDeathGrok(sandbox, { stderrLine, exitCode = 1 } = {}) { + const file = path.join(sandbox.root, "permission-death-grok.mjs"); + fs.writeFileSync( + file, + `#!/usr/bin/env node +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +if (process.env.FAKE_GROK_ARGS_FILE) fs.appendFileSync(process.env.FAKE_GROK_ARGS_FILE, JSON.stringify(process.argv.slice(2)) + "\\n"); +const sandboxIndex = process.argv.indexOf("--sandbox"); +if (sandboxIndex >= 0) { + const profile = process.argv[sandboxIndex + 1]; + const grokHome = Object.hasOwn(process.env, "GROK_HOME") ? path.resolve(process.cwd(), process.env.GROK_HOME) : path.join(os.homedir(), ".grok"); + fs.mkdirSync(grokHome, { recursive: true }); + fs.appendFileSync(path.join(grokHome, "sandbox-events.jsonl"), JSON.stringify({ event_type: "ProfileApplied", profile, workspace: fs.realpathSync(process.cwd()), enforced: true, restrict_network: profile !== "workspace", read_write_paths: [fs.realpathSync(process.cwd()), grokHome, process.env.TMPDIR].filter(Boolean) }) + "\\n"); + process.stderr.write("DEBUG xai_grok_agent::builder: tools allowlist applied\\n"); +} +for await (const chunk of process.stdin) void chunk; +process.stderr.write(${JSON.stringify(`${stderrLine}\n`)}); +process.exit(${exitCode}); +`, + "utf8" + ); + fs.chmodSync(file, 0o755); + return file; +} + +function gitTopLevel(cwd) { + const result = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + encoding: "utf8", + env: { ...process.env, ...gitIsolation }, + stdio: ["ignore", "pipe", "ignore"] + }); + assert.equal(result.status, 0, result.stderr); + return String(result.stdout ?? "").trim(); +} + +test("extractDeniedToolFromStderr names a denied tool when stderr reports it", () => { + assert.equal( + extractDeniedToolFromStderr("Error: permission denied for tool run_terminal_cmd\n"), + "run_terminal_cmd" + ); + assert.equal( + extractDeniedToolFromStderr("Error: tool Bash was denied by the permission gate\n"), + "Bash" + ); + assert.equal(formatDeniedToolDetail("run_terminal_cmd"), "; denied tool: run_terminal_cmd"); +}); + +test("extractDeniedToolFromStderr returns null when stderr has no tool name", () => { + assert.equal(extractDeniedToolFromStderr("Error:\nOperation not permitted (os error 1)\n"), null); + assert.equal(formatDeniedToolDetail(null), ""); +}); + +test("permission death appends denied-tool detail when stderr names a tool", (t) => { + const sandbox = makeSandbox(t); + const grokBin = writePermissionDeathGrok(sandbox, { + stderrLine: "Error: permission denied for tool run_terminal_cmd" + }); + const result = runCompanion(["task", "attempt a blocked shell command"], { + cwd: sandbox.workDir, + env: envFor(sandbox, { GROK_BIN: grokBin }) + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /^failure: permission$/m); + assert.match(result.stderr, /denied tool: run_terminal_cmd/); + assert.doesNotMatch(result.stderr, /blocked call not reported by the CLI/); + const record = jobRecords(sandbox.dataDir)[0]; + assert.equal(record.failureKind, "permission"); + assert.match(record.errorMessage, /denied tool: run_terminal_cmd/); + assert.doesNotMatch(record.errorMessage, /blocked call not reported by the CLI/); +}); + +test("permission death keeps the generic message when stderr omits a tool name", (t) => { + const sandbox = makeSandbox(t); + const result = runCompanion(["task", "read a protected input"], { + cwd: sandbox.workDir, + env: envFor(sandbox, { FAKE_GROK_MODE: "operation-not-permitted" }) + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /^failure: permission$/m); + assert.match(result.stderr, /blocked call not reported by the CLI/); + assert.doesNotMatch(result.stderr, /denied tool:/); + const record = jobRecords(sandbox.dataDir)[0]; + assert.equal(record.failureKind, "permission"); + assert.match(record.errorMessage, /blocked call not reported by the CLI/); + assert.doesNotMatch(record.errorMessage, /denied tool:/); +}); + +test("permission-cancelled death without a named tool stays generic", (t) => { + const sandbox = makeSandbox(t); + const result = runCompanion(["task", "doomed"], { + cwd: sandbox.workDir, + env: envFor(sandbox, { FAKE_GROK_MODE: "permission-cancelled" }) + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /^failure: permission$/m); + assert.match(result.stderr, /blocked call not reported by the CLI/); + assert.doesNotMatch(result.stderr, /denied tool:/); + const record = jobRecords(sandbox.dataDir)[0]; + assert.equal(record.failureKind, "permission"); + assert.match(record.errorMessage, /blocked call not reported by the CLI/); +}); + +test("task job records repositoryTopLevel inside a git directory", (t) => { + const sandbox = makeSandbox(t); + initCleanRepo(sandbox.workDir); + const expected = gitTopLevel(sandbox.workDir); + const result = runCompanion(["task", "record the repository root"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 0, result.stderr); + const record = jobRecords(sandbox.dataDir)[0]; + assert.equal(record.cwd, sandbox.workDir); + assert.equal(record.repositoryTopLevel, expected); +}); + +test("task job records null repositoryTopLevel outside a git directory", (t) => { + const sandbox = makeSandbox(t); + const result = runCompanion(["task", "record a missing repository root"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 0, result.stderr); + const record = jobRecords(sandbox.dataDir)[0]; + assert.equal(record.cwd, sandbox.workDir); + assert.equal(record.repositoryTopLevel, null); +}); From b37d4e140d140ce8e84e940fe5173fc1eb2a0013 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:56 +0800 Subject: [PATCH 07/10] docs(fusion): codify wave settlement and dispatch norms --- plugins/fusion/rules-manifest.json | 1 + plugins/fusion/rules/orchestration.md | 14 ++++++++------ tests/rules-sync.test.mjs | 28 ++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/plugins/fusion/rules-manifest.json b/plugins/fusion/rules-manifest.json index 24f41a3..792946a 100644 --- a/plugins/fusion/rules-manifest.json +++ b/plugins/fusion/rules-manifest.json @@ -2,6 +2,7 @@ "format": 2, "hashes": [ "16d5cc7a7a1df5908043379d4278bd5e7bd8f66a112de0804857ae3cdcc15a60", + "203c59d6a00a0ad9fb41a6a65da73d8909e1e7f519f516a6ab645c4f250a5cd6", "206d5d62bfc699358790244597ba9f8d331c436dfc7af25467d39487a6c0815e", "3106f1dd92b8bc6ded59fe7f6b694eb947b9e6594522442b929474dbc9e6fd5f", "35136a84eefb3d1debe84fecfa0d667ffbfd782efaff5f6bd2bd99ac2994b6dc", diff --git a/plugins/fusion/rules/orchestration.md b/plugins/fusion/rules/orchestration.md index ca62bce..762640c 100644 --- a/plugins/fusion/rules/orchestration.md +++ b/plugins/fusion/rules/orchestration.md @@ -30,6 +30,8 @@ Run the session like the founder of a well staffed startup: you decide, employee Classification is per message; execution posture is per session goal and persists across turns until an exit condition fires. Each turn first decides whether the message continues the active goal or starts an unrelated one; a continued goal keeps the current posture rather than being reclassified from scratch. +A new goal after a heavy implementation goal prefers a fresh session because orchestrator cache reread cost is context size times turns; `FUSION_PARENT_CONTEXT_ADVISORY_BYTES` names the parent context advisory threshold. + There are three postures, and each governs what the main loop is allowed to touch until the goal changes or an exit condition fires. Coordinate is the default for main loop conduct and does not decide execution width, which the fleet default owns. @@ -79,7 +81,7 @@ An isolated Codex worktree changes the execution workspace, not merely the prose | Spec grade: explicit completion criteria, output contract, boundaries, verification command | Codex primary implementation lane; Grok only inside a protected role | A foreground Codex companion call is capped at 10 minutes by the Bash tool. Codex stays foreground by default regardless of complexity, estimated duration, review size, or model. If a package is unlikely to fit the cap, split it into bounded packages. If it cannot be split, bypass Codex and route a bounded isolated package to Grok under `burst`, or use the matching Claude fallback. Never add `--background` as an implicit sizing decision. Only an incoming user request that already includes `--background` may detach Codex, and a receipt from a job created by Fusion orchestration requires one same turn bounded collection attempt through fusion:job-collector. Both forcing an oversized package into the foreground and silently detaching it are routing errors | | Quick scoped package: edits with a recipe, codemods, small fixes across a few files | Codex quick tier, gpt-5.6-terra, by default; Grok under `burst` | A package that fits this row routes to the named Codex volume lane even when Grok is idle and follows the admission ladder above when that workspace is occupied. The dispatch passes `--model gpt-5.6-terra --effort xhigh` as leading companion options in the rescue direct prompt, and the brief header line repeats them for audit; prose inside the brief never selects a model. A Grok burst brief names low effort, or the account's fast coding SKU from the live model listing, because the lane default is its current flagship. A Grok package with a long verification chain or a change surface likely to approach its write turn budget of 60 turns instead consolidates into a bounded spec grade Codex brief, carries an explicit `--max-turns` override, or routes to `fusion:fast-worker`. A max turns death is a routing error, not a retry candidate. Fixture heavy test authoring routes to the Codex quick tier or `fusion:fast-worker` by default. A Grok brief for fixture heavy test authoring is admissible only when the fixtures arrive pre built in the brief, because fixture construction burns Grok turns and stalls `apply_patch` | | Needs the Claude Code tool surface (hooks, subagent files, MCP), the Claude privacy boundary, or a resolved brief with no eligible peer lane | fusion:fast-worker | Claude execution fallback, not the default for spec grade or quick scoped work | -| Trivial and high volume light work: single file renames, small doc fixes, short mechanical checks, drafts, review comment triage, and research digest backup | Codex volume tier, gpt-5.6-luna, by default; Grok under `burst`; fallback fusion:trivial-worker | A package that fits this row routes to the named Codex volume lane even when Grok is idle and follows the admission ladder above when that workspace is occupied. The dispatch passes `--model gpt-5.6-luna --effort xhigh` as leading companion options in the rescue direct prompt, and the brief header line repeats them for audit; prose inside the brief never selects a model. A Grok burst brief names low effort; batch codemods may name the fast SKU from the live listing, currently grok-composer-2.5-fast. Live web research uses Grok consult under `live-web`; very large context reads use `large-context`. Use `fusion:trivial-worker` only when Claude-only tools or privacy are required, or no eligible peer lane remains | +| Trivial and high volume light work: single file renames, small doc fixes, short mechanical checks, drafts, review comment triage, and research digest backup | Codex volume tier, gpt-5.6-luna, by default; Grok under `burst`; fallback fusion:trivial-worker | A package that fits this row routes to the named Codex volume lane even when Grok is idle and follows the admission ladder above when that workspace is occupied. Research digests, review triage, doc summaries, and mechanical checks default to gpt-5.6-luna at effort xhigh. Independent bounded packages overflow to Grok under `burst` when the Codex slot is busy. Leaving the volume tiers idle while this work runs on premium lanes is a routing defect. The dispatch passes `--model gpt-5.6-luna --effort xhigh` as leading companion options in the rescue direct prompt, and the brief header line repeats them for audit; prose inside the brief never selects a model. A Grok burst brief names low effort; batch codemods may name the fast SKU from the live listing, currently grok-composer-2.5-fast. Live web research uses Grok consult under `live-web`; very large context reads use `large-context`. Use `fusion:trivial-worker` only when Claude-only tools or privacy are required, or no eligible peer lane remains | | Review shaped work: PR and issue verification, reproducing reported bugs | /grok:review for the fast mechanical sweep; /codex:adversarial-review for depth | Interactive judgment and taste calls stay in the main loop | | Codebase search, inventory, "where is X" | built in Explore agent | Simple scoped searches may pin the Explore agent to haiku through the Agent tool's model parameter | | Hard reasoning: architecture, root cause on stubborn bugs, correctness, concurrency, security, high stakes quality | fusion:deep-reasoner | Read only adviser on Fable at maximum effort; it recommends and the main session decides | @@ -117,7 +119,7 @@ Peers are executors with model aware lanes. Codex is the primary builder for bou - grok is the complementary specialist and burst lane. Use `/grok:task --write` for bounded implementation under `burst` or `independence`; use read only consult with `--web` under `live-web`; use read only file analysis under `large-context`. Consult, write, and review all use `--sandbox strict`, with no silent downgrade to workspace. Consult exposes only file read, list, and search tools, plus web search and fetch when requested. Write uses a fixed list containing `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`; Grok has no `write` tool id, and `search_replace` creates new files. Command-pattern denies for common direct grok, claude, and codex calls do not cover absolute paths, aliases or functions, or indirect scripts, so they are not a hard nested-execution boundary while `run_terminal_cmd` remains enabled. Hard prevention requires removing that tool or an OS-level executable or network policy. Every ordinary call uses bare `--disallowed-tools Agent`; every mode also denies `search_tool`, `use_tool`, `ask_user_question`, and MCP tools. Upstream parses `--no-subagents`, but the single-turn and agent resolvers do not forward it, while the interactive TUI does apply it; the hard Agent tool deny and `GROK_SUBAGENTS=0` are the effective headless controls. The child sets all eighteen `GROK_CLAUDE_*_ENABLED`, `GROK_CURSOR_*_ENABLED`, and `GROK_CODEX_*_ENABLED` bridge variables to false; upstream currently consumes the six Claude and six Cursor cells plus the Codex sessions cell and reserves the other five Codex cells, and the child also pins `GROK_MANAGED_MCPS_ENABLED=false` because that variable has highest precedence over `[managed_mcps]` in `~/.grok/config.toml` and remote settings. It removes inherited Claude plugin and companion variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, broadly scrubs secrets while preserving xAI authentication, and disables automatic updates with `--no-auto-update`. Cross-session memory is forced off unless a direct user-selected ordinary task explicitly passes `--memory`; automatically routed briefs, review, and stop gate cannot enable it. Upstream minimum-version enforcement may still force an update and remains a residual boundary. Every managed run preflights `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`; consult adds `--permission-mode` and `--allow`, no-web adds `--disable-web-search`, write adds `--always-approve`, review adds `--json-schema`, and an ordinary run requires `--no-wait-for-background` or the bounded wait flag. Forced builder tracing initializes after stdin is consumed, rejects fallback or unmatched warnings as soon as observed, and rejects a successful close without positive tool allowlist applied evidence; it is not a pre-prompt or pre-side-effect attestation. Codex also exposes opt in web search, but Grok remains the preferred live research lane. Design decisions ride in a Grok write brief only once the capability table scores its taste at 4 or higher; until scored, the taste floor applies and design decisions stay out. - Cross engine review by default: the other peer or fusion:deep-reasoner reviews a substantial package before merge; /codex:adversarial-review challenges a design, /grok:review is the fast pass. - A plan with three or more independent packages uses every lane whose ownership or protected role fits, splitting proportionally rather than queueing everything on fusion:fast-worker; this proportional split is exactly what /fusion:ultra performs. Idle capacity alone never manufactures a Grok role. Multi source research fans out one track to a peer by default. -- Balance check: after two eligible packages have gone to Claude workers while Codex sits idle, route the next ordinary implementation package to Codex. Route to idle Grok only when a protected role fits and its circuit breaker is closed. +- Balance check: on a delegating project, peer lanes carry a meaningful share of implementation output. A session where Claude worker lanes absorb spec grade or quick scoped packages while Codex sits idle is a routing defect. After two eligible packages have gone to Claude workers while Codex sits idle, route the next ordinary implementation package to Codex. Route to idle Grok only when a protected role fits and its circuit breaker is closed. - A companion `failure: setup` means its CLI installation, version, or required capability preflight is incompatible. Do not retry the same task against that surface. Upgrade or repair the CLI and rerun the companion setup command; route current work to another healthy eligible lane until the surface is repaired. - Worker reuse, thread rotation, and per engine failure handling: see the fusion plugin's troubleshooting rules (shipped with the plugin, consulted on demand). @@ -144,21 +146,21 @@ Every brief is self contained: goal, constraints, relevant paths, and what done - Implementation and change briefs name every verification suite that exercises the touched behavior, including shared harness suites not named after the touched plugin; a verification list covering only the obviously named suites is not complete. - Peer sandboxes cannot run process dependent suites or reach user toolchains; a peer implementation brief states that such failures are reported as environment findings with passing counts, and the orchestrator reruns the exact verification in a full environment before acceptance. - Before a fleet wave over an uncommitted baseline, the orchestrator records a checkpoint so isolated worktrees fork from the accumulated state; when no commit is authorized, the baseline rides as an applied and staged patch in each worktree and the package delta is exactly the unstaged diff. -- Independent packages and independent sections of one task are decomposed and dispatched in a single message; sequential dispatch of independent work is a defect. Repeated narrow waves on a fleet shaped goal without a visible `fleet-decline: ` line are the same defect. +- Independent packages and independent sections of one task are decomposed and dispatched in a single message; sequential dispatch of independent work is a defect. Repeated width one dispatch turns on a goal with multiple independent packages are the signature of that defect, and the narrow wave watch in `/fusion:stats` surfaces it. Repeated narrow waves on a fleet shaped goal without a visible `fleet-decline: ` line are the same defect. - Agent scheduling, companion delivery, engine process supervision, and Grok's native background tool waiting are separate layers. Every ordinary Agent is foreground delivery. The harness launches Agent calls asynchronously by default, but a launch receipt is not delivery: completion arrives as a task notification, which arms mandatory collection. Every dispatch still requires collection and verification before the package counts as done. Launch independent Agents together in one tool message for concurrency, but do not set `run_in_background` and do not accept an async launch receipt as delivery. `grok:grok-review-runner` is the sole managed exception allowed to use Agent `run_in_background`. Codex and Grok wrapper Agents always remain foreground. Only an incoming user request that explicitly contains companion `--background` authorizes the companion child to detach inside its foreground wrapper and return a manual receipt; it never authorizes background wrapper delivery. Fusion records every Claude worker as an owned task; if Claude Code still launches one asynchronously, the Stop hook blocks turn completion and requires `TaskOutput` collection, or `TaskStop` when a lifecycle budget has expired. Codex companion Bash calls stay foreground with the 10 minute tool timeout. Grok companion calls also remain foreground inside their owning Agent; expected duration does not authorize managed detachment. Ordinary Grok calls prefer `--no-wait-for-background` because their hard `Agent` deny also removes the native task lifecycle; a binary without that flag gets an explicit `--background-wait-timeout` shorter than the companion's outer deadline. That bounded internal wait is not companion detachment. The CLI child running in its own supervised process group is never by itself a user background job. The main loop never runs a work package or launches a detached shell. The coordinate micro step and single triage edit are not work packages. - Heartbeat rule: a legitimate watch style wakeup on delegated work in flight emits one short user visible status line naming what is still in flight and when the next check happens. A wakeup with only tool calls and no visible text is a silent turn and is banned, and so is a hand rolled Bash or ScheduleWakeup polling loop used in place of fusion:job-collector. A wakeup with in flight work emits its status line and otherwise takes no tool action; informationless tool calls on advisory wakeups are a defect. - Same turn collection attempt: this obligation applies to a manual receipt that crosses into the main session from a detached job launched by Fusion orchestration. Follow it in the same turn through fusion:job-collector with a closed direct prompt containing exactly one standalone `engine: codex|grok` line and one standalone `job: <32 lowercase hexadecimal characters>` line. Do not describe or repeat either identity elsewhere in the prompt. The lifecycle binds the collected marker to this dispatch identity. The collector resolves the engine companion itself, performs global job lookup without a working-directory selector, and invokes status plus blocking result through fixed argv without shell command strings. Its single collection window is at most 540000ms. A timeout, dead job, or status error leaves the package uncollected and must reach the user with the Fusion task id, peer job id, the literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. Direct Codex and Grok slash commands with explicit `--background` return manual receipts for the user to inspect and collect through their status and result commands; monitors are notification only. A dispatch whose receipt is never followed by same-goal collection and verification is a contract failure. -- Transport completion is not semantic acceptance. The main session verifies every peer and Claude worker result before relying on it. After collecting and verifying any delegated result, settle its verdict in one call with `/fusion:stats --record =`, where the id is the fusion task id or the engine job id; one settle writes the worker ledger and the linked engine record together, strict record forms may pass as direct arguments, and anything carrying free text still uses the raw-args transport. Record accepted only when the verification command or explicit acceptance criteria pass. Record `rejected` when the collected result or verification fails. Leave it `unverified` only when no reliable judgment was possible. Stats default every terminal job without a recorded judgment to `unverified`; never infer acceptance from `state: done`, `delivery: complete`, or any transport status. +- Transport completion is not semantic acceptance. The main session verifies every peer and Claude worker result before relying on it. Settlement is batched per wave: collect and verify every result in a wave, then record all verdicts in one `/fusion:stats --record` call with multiple `=` pairs, where each id is the fusion task id or the engine job id. Per package settlement turns are reserved for waves of one. One settlement validates every pair before writing and is idempotent to rerun after a partial transport failure; it writes the worker ledger and linked engine record for every recorded verdict. Strict record forms may pass as direct arguments, and anything carrying free text still uses the raw-args transport. Record accepted only when the verification command or explicit acceptance criteria pass. Record `rejected` when the collected result or verification fails. Leave it `unverified` only when no reliable judgment was possible. Every collected result is settled in the wave in which it is collected. `unverified` is temporary and must not survive the goal's completion. Stats default every terminal job without a recorded judgment to `unverified`; never infer acceptance from `state: done`, `delivery: complete`, or any transport status. - A non deliverable final message starts an obligation, not an outcome: a background receipt created by Fusion and a truncated run ending in forward looking narration or missing verification are both unfinished. Resume a truncated, non forwarder agent with SendMessage to finish and report with verification; for a background receipt created by Fusion, dispatch fusion:job-collector instead. A foreground Codex receipt remains a contract failure under the preceding rule. - Related follow up packages reuse the existing worker thread via SendMessage instead of cold starting a new agent, unless the thread rotation rules in the fusion plugin's troubleshooting rules (shipped with the plugin, consulted on demand) say otherwise. - Parallel packages declare each other: every brief names sibling packages' files as intended in flight changes and forbids reverting, restoring, or cleaning anything outside its own list. A worker's end state check covers its own files only. -- A worktree isolated peer package is collected by merging its diff back into the main tree and re-running its verification command there before the package counts as done; for Codex, record semantic acceptance only after that main tree verification. The orchestrator creates a real Git worktree first and launches the peer with that canonical directory as its actual process cwd. Grok headless `--worktree` and `--worktree-ref` are not substitutes because the current headless path does not reliably create, select, or forward those worktree settings. Worktree runs leave disposable per-path state directories in the engine's data root, and /fusion:stats --prune-dead is the cleanup path once the worktree itself is gone. Worktrees hosting detached peer jobs are created and removed by the orchestrator with git worktree add and git worktree remove; never tie such a worktree to a wrapper agent's lifetime, because harness managed agent worktrees are auto removed when the wrapper agent exits, which deletes the workspace out from under a still running detached job. +- A worktree isolated peer package is collected by merging its diff back into the main tree and re-running its verification command there before the package counts as done; for Codex, record semantic acceptance only after that main tree verification. The orchestrator creates a real Git worktree first and launches the peer with that canonical directory as its actual process cwd. Grok headless `--worktree` and `--worktree-ref` are not substitutes because the current headless path does not reliably create, select, or forward those worktree settings. Worktree runs leave disposable per-path state directories in the engine's data root, and /fusion:stats --prune-dead is the cleanup path once the worktree itself is gone. After each release, `/fusion:stats --prune-dead` runs as part of the release cadence. Worktrees hosting detached peer jobs are created and removed by the orchestrator with git worktree add and git worktree remove; never tie such a worktree to a wrapper agent's lifetime, because harness managed agent worktrees are auto removed when the wrapper agent exits, which deletes the workspace out from under a still running detached job. - State write permission explicitly in every peer brief. Every Grok mode uses strict. Its upstream `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`. The run's unique private `TMPDIR` is the adapter's preferred and handshake-verified temporary path, not the only writable temporary surface. User toolchains outside the readable roots may therefore be unavailable, and their absence never authorizes a workspace downgrade. Strict requests child process network restriction. Linux enforces it through seccomp, while macOS network blocking is a no-op and a write shell can still reach the network; `--web` controls only the built in web tools. Grok consult briefs and /grok:review add a hard read only tool set limited to file read, list, and search, plus web search and fetch when `--web` is present. They cannot invoke shell commands, tests, git, builds, edits, MCP tools, or subagents through model tools. The sandbox still permits consult `read_file` to reach `~/.grok/auth.json`, config, and sessions because strict exposes the entire Grok home, and write terminal commands can receive preserved xAI authentication variables. The companion adds best-effort Read denies for `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` through absolute paths and standard `**/.grok` patterns, but raw path variants, symbolic links, and shell or indirect scripts can bypass those tool-level rules. These Read denies govern model tool calls and do not stop upstream's internal first-turn injection for a direct task with explicit `--memory`; injected memory enters the model context sent to xAI. Environment scrubbing and path-pattern denies do not isolate credentials or memory content. Model-facing meta-tool denies do not prove that native MCP servers, plugins, or hooks under `~/.grok` did not start during agent construction or had no side effects; those bridge variables disable compatibility imports, not native Grok configuration. Narrower exposure requires a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization. Only briefs asking for repository changes receive the strict write tool set. Before prompt delivery, the companion requires a new `ProfileApplied` event in the incremental shared `sandbox-events.jsonl` delta that contains this run's private temporary path and matches the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. Upstream events have no run id or pid, so the unique private path correlates positive evidence; unrelated `ProfileApplied` and `ApplyFailed` records are not attributed, and failure records are only auxiliary diagnostics. The matching event proves profile and configuration application, not actual platform network isolation. A matching warning on the owned stderr stream, handshake timeout, missing or malformed matching evidence, shared-log disappearance or rotation, or any matching field mismatch terminates the process and fails closed; stderr cannot prove successful enforcement. - Every peer brief opens with a single header line that names the lane, the effort or model tier whenever the routing row requires one, and the verification command for an implementation or change brief, or explicit coverage or acceptance criteria for a consult, research, or review brief; every automatically routed Grok header also includes `grok-role: `. Pass the brief directly in the Agent prompt. Never create a transport prompt file with Write or place a brief under plugin data. The Grok companion must use `--prompt-file /dev/stdin` and pipe the prompt to the child because Grok applies the sandbox before reading a prompt file, which makes an external staged brief unreadable under strict mode. Companion adapters own any private staging required by their transports. Collection review verifies consult, research, and review criteria. A brief missing that header is not ready to dispatch. The header line is audit prose: engine model and effort selection happens only through companion options such as `--model` and `--effort` placed before the `--` separator in the direct prompt, and a routing row that names a model tier is satisfied only when the dispatch passes those options. - Grok review requires the installed headless `--json-schema` contract and consumes `structuredOutput` from the same turn. A schema error or explicit null structured result is a failed review, not permission to trust text or spend a second call. Compatibility parsing of `text` is allowed only when the structured output field is completely absent. Preserve request id, session id, turn count, token usage, per-model usage, cost, partial-cost, and incomplete-usage indicators in job records whenever the CLI reports them. A `model_usage` row reports only input, output, cache-read, and model-call counts plus optional cost, so never synthesize aggregate reasoning or total token channels. `usage_is_incomplete` means the usage ledger may have missed open subagents, usage application, or a drain timeout; present values are observed lower bounds, and both job-total token and cost coverage fail closed to incomplete. Accept only positive complete top-level cost pairs and use ticks as authoritative at 10000000000 ticks per USD. These fields improve observability but never replace independent semantic acceptance. - A future Grok ACP adapter must use one process pool per canonical cwd and sandbox profile. Each daemon starts directly with `grok --cwd --sandbox agent --no-leader stdio`; a global bridge or leader reuse is forbidden because sandbox installation happens once at process start and an existing leader can retain a different cwd or sandbox. ACP permission requests and live tool events improve visibility, but do not weaken the companion's tool, worktree, background, collection, or semantic acceptance boundaries. - Never delegate to codex or grok anything touching secrets, credentials, or context that cannot be compressed into a brief. -- Runtime guard: the hook permits five main loop write calls in each dispatch window by default, warns when that budget is reached, then denies further main loop writes until an Agent or Task call completes successfully. Subagent writes never enter this budget. Each successful dispatch starts a fresh window. `FUSION_INLINE_WRITE_BUDGET` changes the limit. `FUSION_INLINE_GUARD_MODE=advisory` preserves the former nonblocking behavior for compatibility. +- Runtime guard: the hook permits five main loop write calls in each dispatch window by default. After that budget, it permits up to three small Edit calls with replacement content at most 1024 bytes for files already written in the window, and in a session with no dispatches it warns instead of denying through ten total writes and 16384 inline bytes; new path Write calls and NotebookEdit remain denied. Each successful Agent or Task call resets the window; `FUSION_INLINE_WRITE_BUDGET`, `FUSION_INLINE_TAIL_MAX_BYTES`, `FUSION_INLINE_TAIL_ALLOWANCE`, `FUSION_INLINE_ZERO_DISPATCH_MAX_BYTES`, and `FUSION_INLINE_ZERO_DISPATCH_WRITES` configure the limits, and `FUSION_INLINE_GUARD_MODE=advisory` preserves the former nonblocking behavior for compatibility. - A review job whose findings are consumed by a follow-up dispatch is settled like any other package; consuming the findings without recording the review's verdict is a settlement gap. - When any package in a wave terminates by timeout with partial output, rerun that package's verification plus one broad typecheck or test pass over the merged tree before accepting sibling packages that share the subsystem. - Before dispatching Codex into a workspace, confirm its single-flight slot is free or route down the admission ladder; a dispatch bounced with an already-running error is a routing error, not a retry candidate. diff --git a/tests/rules-sync.test.mjs b/tests/rules-sync.test.mjs index eebcce0..46d91bf 100644 --- a/tests/rules-sync.test.mjs +++ b/tests/rules-sync.test.mjs @@ -233,6 +233,17 @@ test("Grok rules document source verified headless boundaries", () => { const subagentSentence = "Upstream parses `--no-subagents`, but the single-turn and agent resolvers do not forward it, while the interactive TUI does apply it; the hard Agent tool deny and `GROK_SUBAGENTS=0` are the effective headless controls."; const rustLogSentence = "Every run forces `RUST_LOG=xai_grok_agent::builder=debug,xai_grok_sandbox=warn`."; const bubblewrapSentence = "On Linux, a strict profile with deny paths re-executes under bubblewrap and refuses to start when `bwrap` is missing or unusable; macOS has no equivalent hard enforcement and relies on Seatbelt application plus best-effort tool denies."; + const freshSessionSentence = "A new goal after a heavy implementation goal prefers a fresh session because orchestrator cache reread cost is context size times turns; `FUSION_PARENT_CONTEXT_ADVISORY_BYTES` names the parent context advisory threshold."; + const volumeTierSentence = "Research digests, review triage, doc summaries, and mechanical checks default to gpt-5.6-luna at effort xhigh."; + const volumePeerOffloadSentence = "Independent bounded packages overflow to Grok under `burst` when the Codex slot is busy."; + const volumeRoutingDefectSentence = "Leaving the volume tiers idle while this work runs on premium lanes is a routing defect."; + const balanceCheckSentence = "Balance check: on a delegating project, peer lanes carry a meaningful share of implementation output. A session where Claude worker lanes absorb spec grade or quick scoped packages while Codex sits idle is a routing defect."; + const narrowWaveSentence = "Repeated width one dispatch turns on a goal with multiple independent packages are the signature of that defect, and the narrow wave watch in `/fusion:stats` surfaces it."; + const settlementSentence = "Settlement is batched per wave: collect and verify every result in a wave, then record all verdicts in one `/fusion:stats --record` call with multiple `=` pairs, where each id is the fusion task id or the engine job id."; + const perPackageSettlementSentence = "Per package settlement turns are reserved for waves of one."; + const settlementInvariantSentence = "One settlement validates every pair before writing and is idempotent to rerun after a partial transport failure; it writes the worker ledger and linked engine record for every recorded verdict."; + const settlementTransportSentence = "Strict record forms may pass as direct arguments, and anything carrying free text still uses the raw-args transport."; + const releaseCadenceSentence = "After each release, `/fusion:stats --prune-dead` runs as part of the release cadence."; for (const text of [rules, troubleshooting, runtime, contract, readme, security, doctor]) { assert.ok(text.includes(bridgeSentence), "Grok bridge isolation wording must stay synchronized"); @@ -318,7 +329,22 @@ test("Grok rules document source verified headless boundaries", () => { assert.match(rules, /sandbox still permits consult `read_file` to reach `~\/\.grok\/auth\.json`/i); assert.match(rules, /native MCP servers, plugins, or hooks/); assert.match(rules, /bridge variables disable compatibility imports, not native Grok configuration/); - assert.match(rules, /After collecting and verifying any delegated result, settle its verdict in one call with `\/fusion:stats --record =`, where the id is the fusion task id or the engine job id; one settle writes the worker ledger and the linked engine record together, strict record forms may pass as direct arguments, and anything carrying free text still uses the raw-args transport\. Record accepted only when the verification command or explicit acceptance criteria pass\./); + for (const sentence of [ + freshSessionSentence, + volumeTierSentence, + volumePeerOffloadSentence, + volumeRoutingDefectSentence, + balanceCheckSentence, + narrowWaveSentence, + settlementSentence, + perPackageSettlementSentence, + settlementInvariantSentence, + settlementTransportSentence, + releaseCadenceSentence + ]) { + assert.ok(rules.includes(sentence), `Rules policy sentence must stay synchronized: ${sentence}`); + } + assert.match(rules, /Record accepted only when the verification command or explicit acceptance criteria pass\./); assert.match(rules, /literal state `uncollected`/); assert.match(rules, /one process pool per canonical cwd and sandbox profile/); assert.match(rules, /agent --no-leader stdio/); From 8c84f09110979740b7225d78642a88e653c9fc60 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:56 +0800 Subject: [PATCH 08/10] test(fusion): peer wrapper completion contract canary --- tests/codex-wrapper-contract.test.mjs | 212 ++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/codex-wrapper-contract.test.mjs diff --git a/tests/codex-wrapper-contract.test.mjs b/tests/codex-wrapper-contract.test.mjs new file mode 100644 index 0000000..3c8a69d --- /dev/null +++ b/tests/codex-wrapper-contract.test.mjs @@ -0,0 +1,212 @@ +import assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { readWorkerRecords } from "../plugins/fusion/scripts/lib/worker-state.mjs"; + +const repoRoot = path.join(import.meta.dirname, ".."); +const script = path.join(repoRoot, "plugins", "fusion", "scripts", "worker-lifecycle.mjs"); +const hooksConfigPath = path.join(repoRoot, "plugins", "fusion", "hooks", "hooks.json"); + +function sandbox(t) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "fusion-wrapper-contract-test-"))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const cwd = path.join(root, "repo"); + const state = path.join(root, "workers"); + const transcript = path.join(root, "parent.jsonl"); + fs.mkdirSync(cwd, { recursive: true }); + fs.writeFileSync(transcript, "", "utf8"); + return { root, cwd, state, transcript }; +} + +function envFor(box, extra = {}) { + return { + ...process.env, + FUSION_WORKER_STATE_DIR: box.state, + FUSION_DATA_DIR: path.join(box.root, "fusion-data"), + ...extra, + }; +} + +function run(box, payload, extra = {}) { + return spawnSync(process.execPath, [script], { input: JSON.stringify(payload), encoding: "utf8", env: envFor(box, extra) }); +} + +function brief() { + return "fusion-brief: v1\ncontext-mode: isolated\ngoal: implement one fix\nscope: src/a.ts\nverification: node --test\n"; +} + +function dispatch(box, overrides = {}) { + return { + hook_event_name: "PreToolUse", + session_id: "session-1", + tool_use_id: "tool-1", + transcript_path: box.transcript, + cwd: box.cwd, + tool_name: "Agent", + tool_input: { subagent_type: "fusion:fast-worker", description: "fix a", prompt: brief(), ...overrides } + }; +} + +function record(box) { + const records = readWorkerRecords(envFor(box)); + assert.strictEqual(records.length, 1); + return records[0]; +} + +test("a codex:codex-rescue final carrying the companion envelope completes without a stop block", (t) => { + const box = sandbox(t); + const peerJobId = "3".repeat(32); + const peerDispatch = dispatch(box, { subagent_type: "codex:codex-rescue", prompt: "bounded peer brief" }); + run(box, peerDispatch); + run(box, { + ...peerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "codex-envelope-pass" } + }); + + const stopped = run(box, { + hook_event_name: "SubagentStop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: "codex-envelope-pass", + agent_type: "codex:codex-rescue", + stop_hook_active: false, + last_assistant_message: ["companion result prose", "codex-session: session-1", `job: ${peerJobId}`, "semantic: unverified", "state: done"].join("\n") + }); + + assert.strictEqual(stopped.stdout, ""); + const completed = record(box); + assert.strictEqual(completed.retryCount, 0); + assert.strictEqual(completed.failureKind, null); + assert.strictEqual(completed.peerJobId, peerJobId); + assert.strictEqual(completed.peerEngine, "codex"); + assert.strictEqual(completed.transportStatus, "ready_uncollected"); +}); + +test("a grok:grok-rescue final carrying the companion envelope completes without a stop block", (t) => { + const box = sandbox(t); + const peerJobId = "4".repeat(32); + const peerDispatch = dispatch(box, { subagent_type: "grok:grok-rescue", prompt: "bounded peer brief" }); + run(box, peerDispatch); + run(box, { + ...peerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "grok-envelope-pass" } + }); + + const stopped = run(box, { + hook_event_name: "SubagentStop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: "grok-envelope-pass", + agent_type: "grok:grok-rescue", + stop_hook_active: false, + last_assistant_message: ["companion result prose", "grok-session: grok-session-9", `job: ${peerJobId}`, "state: done"].join("\n") + }); + + assert.strictEqual(stopped.stdout, ""); + const completed = record(box); + assert.strictEqual(completed.retryCount, 0); + assert.strictEqual(completed.failureKind, null); + assert.strictEqual(completed.peerJobId, peerJobId); + assert.strictEqual(completed.peerEngine, "grok"); + assert.strictEqual(completed.transportStatus, "ready_uncollected"); +}); + +test("a codex:codex-rescue final that is truncated forward-looking narration blocks exactly once and then terminates without looping", (t) => { + const box = sandbox(t); + const peerDispatch = dispatch(box, { subagent_type: "codex:codex-rescue", prompt: "bounded peer brief" }); + run(box, peerDispatch); + run(box, { + ...peerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "codex-narration-only" } + }); + const stopPayload = { + hook_event_name: "SubagentStop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: "codex-narration-only", + agent_type: "codex:codex-rescue", + stop_hook_active: false, + last_assistant_message: "I still need to confirm the fix and will report the final result shortly." + }; + + const first = run(box, stopPayload); + const firstOutput = JSON.parse(first.stdout); + assert.strictEqual(firstOutput.decision, "block"); + assert.strictEqual(firstOutput.reason, "The transport relay is incomplete. Return the companion output verbatim, including its `job:` and `state:` footer lines. This is the only retry."); + assert.strictEqual(record(box).retryCount, 1); + + const second = run(box, stopPayload); + assert.strictEqual(second.stdout, ""); + const settled = record(box); + assert.strictEqual(settled.transportStatus, "incomplete"); + assert.strictEqual(settled.failureKind, "delivery"); + assert.strictEqual(settled.retryCount, 1); + + const third = run(box, stopPayload); + assert.strictEqual(third.stdout, ""); + const stable = record(box); + assert.strictEqual(stable.transportStatus, "incomplete"); + assert.strictEqual(stable.failureKind, "delivery"); + assert.strictEqual(stable.retryCount, 1); +}); + +test("a fusion:fast-worker final without EXECUTION_END_MARKER is still blocked", (t) => { + const box = sandbox(t); + run(box, dispatch(box)); + run(box, { + hook_event_name: "SubagentStart", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: "fast-worker-narration-only", + agent_type: "fusion:fast-worker" + }); + + const blocked = run(box, { + hook_event_name: "SubagentStop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: "fast-worker-narration-only", + agent_type: "fusion:fast-worker", + stop_hook_active: false, + last_assistant_message: "I still need to confirm the fix and will report the final result shortly." + }); + + const blockedOutput = JSON.parse(blocked.stdout); + assert.strictEqual(blockedOutput.decision, "block"); + assert.strictEqual(blockedOutput.reason, "The task is not deliverable yet. Complete the requested verification and return the actual result. End with `delivery: complete` plus `verification: passed`. This is the only retry."); + const stopped = record(box); + assert.strictEqual(stopped.retryCount, 1); + assert.strictEqual(stopped.transportStatus, "running"); +}); + +test("the SubagentStop matcher matches all seven peer and Claude worker agent names and rejects an unrelated agent", () => { + const hooks = JSON.parse(fs.readFileSync(hooksConfigPath, "utf8")).hooks; + const group = hooks.SubagentStop.find((candidate) => candidate.hooks.some((hook) => hook.command?.includes("worker-lifecycle.mjs"))); + assert.ok(group); + const matcher = new RegExp(group.matcher); + const agentNames = [ + "fusion:fast-worker", + "fusion:trivial-worker", + "fusion:deep-reasoner", + "fusion:job-collector", + "codex:codex-rescue", + "grok:grok-rescue", + "grok:grok-review-runner" + ]; + for (const name of agentNames) { + assert.match(name, matcher); + } + assert.doesNotMatch("general-purpose", matcher); +}); From d33f40bcdbd40faea75f8549f9dbcc98e7b03226 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:19:56 +0800 Subject: [PATCH 09/10] feat(bench): complete the eight task suite --- bench/tasks/T02-negative-control/brief.md | 1 + .../fixtures/package.json | 3 + .../T02-negative-control/fixtures/range.js | 23 + .../T02-negative-control/mutant/package.json | 3 + .../T02-negative-control/mutant/range.js | 23 + .../tasks/T02-negative-control/range.test.mjs | 32 + bench/tasks/T02-negative-control/selftest.sh | 26 + .../solution/package.json | 3 + .../T02-negative-control/solution/range.js | 23 + bench/tasks/T02-negative-control/verify.sh | 30 + bench/tasks/T03-spec-implementation/brief.md | 12 + .../event-bus.test.mjs | 123 ++++ .../fixtures/event-bus.js | 13 + .../T03-spec-implementation/fixtures/index.js | 2 + .../fixtures/match-pattern.js | 17 + .../fixtures/package.json | 3 + .../fixtures/subscriptions.js | 15 + .../mutant/event-bus.js | 47 ++ .../T03-spec-implementation/mutant/index.js | 2 + .../mutant/match-pattern.js | 39 + .../mutant/package.json | 3 + .../mutant/subscriptions.js | 29 + .../tasks/T03-spec-implementation/selftest.sh | 26 + .../solution/event-bus.js | 47 ++ .../T03-spec-implementation/solution/index.js | 2 + .../solution/match-pattern.js | 37 + .../solution/package.json | 3 + .../solution/subscriptions.js | 29 + bench/tasks/T03-spec-implementation/verify.sh | 30 + bench/tasks/T04-checkable-research/brief.md | 15 + .../T04-checkable-research/check-answer.mjs | 53 ++ .../fixtures/corpus/changelog-v1-v2.md | 19 + .../fixtures/corpus/changelog-v3.md | 15 + .../fixtures/corpus/changelog-v4.md | 15 + .../fixtures/corpus/config-reference.md | 9 + .../fixtures/corpus/deprecation-notice.md | 11 + .../fixtures/corpus/faq.md | 17 + .../fixtures/corpus/known-issues.md | 9 + .../corpus/migration-guide-v3-to-v4.md | 15 + .../fixtures/corpus/release-notes-v2.0.0.md | 15 + .../fixtures/corpus/release-notes-v4.1.0.md | 11 + .../fixtures/corpus/support-ticket-digest.md | 3 + .../T04-checkable-research/mutant/answer.json | 14 + .../tasks/T04-checkable-research/selftest.sh | 26 + .../solution/answer.json | 14 + bench/tasks/T04-checkable-research/verify.sh | 23 + bench/tasks/T05-mechanical-codemod/brief.md | 1 + .../T05-mechanical-codemod/fixtures/alert.js | 9 + .../T05-mechanical-codemod/fixtures/digest.js | 9 + .../T05-mechanical-codemod/fixtures/format.js | 11 + .../fixtures/greeting.js | 9 + .../T05-mechanical-codemod/fixtures/index.js | 9 + .../T05-mechanical-codemod/fixtures/invite.js | 9 + .../fixtures/package.json | 3 + .../fixtures/profile.js | 9 + .../fixtures/receipt.js | 9 + .../fixtures/reminder.js | 9 + .../T05-mechanical-codemod/fixtures/status.js | 9 + .../fixtures/summary.js | 9 + .../T05-mechanical-codemod/format.test.mjs | 35 + .../T05-mechanical-codemod/mutant/alert.js | 6 + .../T05-mechanical-codemod/mutant/digest.js | 6 + .../T05-mechanical-codemod/mutant/format.js | 11 + .../T05-mechanical-codemod/mutant/greeting.js | 6 + .../T05-mechanical-codemod/mutant/index.js | 9 + .../T05-mechanical-codemod/mutant/invite.js | 9 + .../mutant/package.json | 3 + .../T05-mechanical-codemod/mutant/profile.js | 6 + .../T05-mechanical-codemod/mutant/receipt.js | 6 + .../T05-mechanical-codemod/mutant/reminder.js | 9 + .../T05-mechanical-codemod/mutant/status.js | 6 + .../T05-mechanical-codemod/mutant/summary.js | 6 + .../tasks/T05-mechanical-codemod/selftest.sh | 26 + .../T05-mechanical-codemod/solution/alert.js | 6 + .../T05-mechanical-codemod/solution/digest.js | 6 + .../T05-mechanical-codemod/solution/format.js | 7 + .../solution/greeting.js | 6 + .../T05-mechanical-codemod/solution/index.js | 9 + .../T05-mechanical-codemod/solution/invite.js | 6 + .../solution/package.json | 3 + .../solution/profile.js | 6 + .../solution/receipt.js | 6 + .../solution/reminder.js | 6 + .../T05-mechanical-codemod/solution/status.js | 6 + .../solution/summary.js | 6 + bench/tasks/T05-mechanical-codemod/verify.sh | 38 + bench/tasks/T06-plan-shaped-fixes/brief.md | 33 + .../fixtures/csv-parser/index.js | 3 + .../fixtures/csv-parser/package.json | 3 + .../fixtures/duration-formatter/index.js | 6 + .../fixtures/duration-formatter/package.json | 3 + .../fixtures/path-matcher/index.js | 5 + .../fixtures/path-matcher/package.json | 3 + .../hidden/csv-parser.test.mjs | 15 + .../hidden/duration-formatter.test.mjs | 13 + .../hidden/path-matcher.test.mjs | 12 + .../mutant/csv-parser/index.js | 25 + .../mutant/csv-parser/package.json | 3 + .../mutant/duration-formatter/index.js | 6 + .../mutant/duration-formatter/package.json | 3 + .../mutant/path-matcher/index.js | 5 + .../mutant/path-matcher/package.json | 3 + bench/tasks/T06-plan-shaped-fixes/selftest.sh | 26 + .../solution/csv-parser/index.js | 25 + .../solution/csv-parser/package.json | 3 + .../solution/duration-formatter/index.js | 6 + .../solution/duration-formatter/package.json | 3 + .../solution/path-matcher/index.js | 5 + .../solution/path-matcher/package.json | 3 + bench/tasks/T06-plan-shaped-fixes/verify.sh | 24 + bench/tasks/T07-plan-shaped-features/brief.md | 31 + .../fixtures/package.json | 3 + .../fixtures/query-string/index.js | 7 + .../fixtures/semver-range/index.js | 11 + .../fixtures/text-table/index.js | 7 + .../mutant/package.json | 3 + .../mutant/query-string/index.js | 50 ++ .../mutant/semver-range/index.js | 98 +++ .../mutant/text-table/index.js | 45 ++ .../query-string.test.mjs | 37 + .../T07-plan-shaped-features/selftest.sh | 26 + .../semver-range.test.mjs | 47 ++ .../solution/package.json | 3 + .../solution/query-string/index.js | 50 ++ .../solution/semver-range/index.js | 98 +++ .../solution/text-table/index.js | 45 ++ .../text-table.test.mjs | 30 + .../tasks/T07-plan-shaped-features/verify.sh | 24 + .../tasks/T08-plan-shaped-migrations/brief.md | 17 + .../mailer-settings/load-mailer-settings.js | 21 + .../fixtures/mailer-settings/mailer.env | 3 + .../fixtures/notifier-settings/config.json | 7 + .../load-notifier-settings.js | 10 + .../fixtures/package.json | 3 + .../fixtures/queue-settings/config.json | 5 + .../queue-settings/load-queue-settings.js | 10 + .../mailer-settings.test.mjs | 19 + .../mailer-settings/load-mailer-settings.js | 10 + .../mutant/mailer-settings/mailer.json | 5 + .../mutant/notifier-settings/config.json | 7 + .../load-notifier-settings.js | 10 + .../mutant/package.json | 3 + .../mutant/queue-settings/config.json | 7 + .../queue-settings/load-queue-settings.js | 10 + .../notifier-settings.test.mjs | 15 + .../queue-settings.test.mjs | 16 + .../T08-plan-shaped-migrations/selftest.sh | 26 + .../mailer-settings/load-mailer-settings.js | 10 + .../solution/mailer-settings/mailer.json | 5 + .../solution/notifier-settings/config.json | 7 + .../load-notifier-settings.js | 10 + .../solution/package.json | 3 + .../solution/queue-settings/config.json | 7 + .../queue-settings/load-queue-settings.js | 10 + .../T08-plan-shaped-migrations/verify.sh | 24 + bench/tasks/manifest.json | 664 +++++++++++++++++- 156 files changed, 3084 insertions(+), 1 deletion(-) create mode 100644 bench/tasks/T02-negative-control/brief.md create mode 100644 bench/tasks/T02-negative-control/fixtures/package.json create mode 100644 bench/tasks/T02-negative-control/fixtures/range.js create mode 100644 bench/tasks/T02-negative-control/mutant/package.json create mode 100644 bench/tasks/T02-negative-control/mutant/range.js create mode 100644 bench/tasks/T02-negative-control/range.test.mjs create mode 100755 bench/tasks/T02-negative-control/selftest.sh create mode 100644 bench/tasks/T02-negative-control/solution/package.json create mode 100644 bench/tasks/T02-negative-control/solution/range.js create mode 100755 bench/tasks/T02-negative-control/verify.sh create mode 100644 bench/tasks/T03-spec-implementation/brief.md create mode 100644 bench/tasks/T03-spec-implementation/event-bus.test.mjs create mode 100644 bench/tasks/T03-spec-implementation/fixtures/event-bus.js create mode 100644 bench/tasks/T03-spec-implementation/fixtures/index.js create mode 100644 bench/tasks/T03-spec-implementation/fixtures/match-pattern.js create mode 100644 bench/tasks/T03-spec-implementation/fixtures/package.json create mode 100644 bench/tasks/T03-spec-implementation/fixtures/subscriptions.js create mode 100644 bench/tasks/T03-spec-implementation/mutant/event-bus.js create mode 100644 bench/tasks/T03-spec-implementation/mutant/index.js create mode 100644 bench/tasks/T03-spec-implementation/mutant/match-pattern.js create mode 100644 bench/tasks/T03-spec-implementation/mutant/package.json create mode 100644 bench/tasks/T03-spec-implementation/mutant/subscriptions.js create mode 100755 bench/tasks/T03-spec-implementation/selftest.sh create mode 100644 bench/tasks/T03-spec-implementation/solution/event-bus.js create mode 100644 bench/tasks/T03-spec-implementation/solution/index.js create mode 100644 bench/tasks/T03-spec-implementation/solution/match-pattern.js create mode 100644 bench/tasks/T03-spec-implementation/solution/package.json create mode 100644 bench/tasks/T03-spec-implementation/solution/subscriptions.js create mode 100755 bench/tasks/T03-spec-implementation/verify.sh create mode 100644 bench/tasks/T04-checkable-research/brief.md create mode 100755 bench/tasks/T04-checkable-research/check-answer.mjs create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v1-v2.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v3.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v4.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/config-reference.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/deprecation-notice.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/faq.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/known-issues.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/migration-guide-v3-to-v4.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v2.0.0.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v4.1.0.md create mode 100644 bench/tasks/T04-checkable-research/fixtures/corpus/support-ticket-digest.md create mode 100644 bench/tasks/T04-checkable-research/mutant/answer.json create mode 100755 bench/tasks/T04-checkable-research/selftest.sh create mode 100644 bench/tasks/T04-checkable-research/solution/answer.json create mode 100755 bench/tasks/T04-checkable-research/verify.sh create mode 100644 bench/tasks/T05-mechanical-codemod/brief.md create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/alert.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/digest.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/format.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/greeting.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/index.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/invite.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/package.json create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/profile.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/receipt.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/reminder.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/status.js create mode 100644 bench/tasks/T05-mechanical-codemod/fixtures/summary.js create mode 100644 bench/tasks/T05-mechanical-codemod/format.test.mjs create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/alert.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/digest.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/format.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/greeting.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/index.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/invite.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/package.json create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/profile.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/receipt.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/reminder.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/status.js create mode 100644 bench/tasks/T05-mechanical-codemod/mutant/summary.js create mode 100755 bench/tasks/T05-mechanical-codemod/selftest.sh create mode 100644 bench/tasks/T05-mechanical-codemod/solution/alert.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/digest.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/format.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/greeting.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/index.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/invite.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/package.json create mode 100644 bench/tasks/T05-mechanical-codemod/solution/profile.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/receipt.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/reminder.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/status.js create mode 100644 bench/tasks/T05-mechanical-codemod/solution/summary.js create mode 100755 bench/tasks/T05-mechanical-codemod/verify.sh create mode 100644 bench/tasks/T06-plan-shaped-fixes/brief.md create mode 100644 bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/package.json create mode 100644 bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/package.json create mode 100644 bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/package.json create mode 100644 bench/tasks/T06-plan-shaped-fixes/hidden/csv-parser.test.mjs create mode 100644 bench/tasks/T06-plan-shaped-fixes/hidden/duration-formatter.test.mjs create mode 100644 bench/tasks/T06-plan-shaped-fixes/hidden/path-matcher.test.mjs create mode 100644 bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/package.json create mode 100644 bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/package.json create mode 100644 bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/package.json create mode 100755 bench/tasks/T06-plan-shaped-fixes/selftest.sh create mode 100644 bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/package.json create mode 100644 bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/package.json create mode 100644 bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/index.js create mode 100644 bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/package.json create mode 100755 bench/tasks/T06-plan-shaped-fixes/verify.sh create mode 100644 bench/tasks/T07-plan-shaped-features/brief.md create mode 100644 bench/tasks/T07-plan-shaped-features/fixtures/package.json create mode 100644 bench/tasks/T07-plan-shaped-features/fixtures/query-string/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/fixtures/semver-range/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/fixtures/text-table/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/mutant/package.json create mode 100644 bench/tasks/T07-plan-shaped-features/mutant/query-string/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/mutant/semver-range/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/mutant/text-table/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/query-string.test.mjs create mode 100755 bench/tasks/T07-plan-shaped-features/selftest.sh create mode 100644 bench/tasks/T07-plan-shaped-features/semver-range.test.mjs create mode 100644 bench/tasks/T07-plan-shaped-features/solution/package.json create mode 100644 bench/tasks/T07-plan-shaped-features/solution/query-string/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/solution/semver-range/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/solution/text-table/index.js create mode 100644 bench/tasks/T07-plan-shaped-features/text-table.test.mjs create mode 100755 bench/tasks/T07-plan-shaped-features/verify.sh create mode 100644 bench/tasks/T08-plan-shaped-migrations/brief.md create mode 100644 bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/load-mailer-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/mailer.env create mode 100644 bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/config.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/load-notifier-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/fixtures/package.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/config.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/load-queue-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/mailer-settings.test.mjs create mode 100644 bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/load-mailer-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/mailer.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/config.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/load-notifier-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/mutant/package.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/config.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/load-queue-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/notifier-settings.test.mjs create mode 100644 bench/tasks/T08-plan-shaped-migrations/queue-settings.test.mjs create mode 100755 bench/tasks/T08-plan-shaped-migrations/selftest.sh create mode 100644 bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/load-mailer-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/mailer.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/config.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/load-notifier-settings.js create mode 100644 bench/tasks/T08-plan-shaped-migrations/solution/package.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/config.json create mode 100644 bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/load-queue-settings.js create mode 100755 bench/tasks/T08-plan-shaped-migrations/verify.sh diff --git a/bench/tasks/T02-negative-control/brief.md b/bench/tasks/T02-negative-control/brief.md new file mode 100644 index 0000000..2fd15bc --- /dev/null +++ b/bench/tasks/T02-negative-control/brief.md @@ -0,0 +1 @@ +This task is the benchmark suite's negative control, a small single file fix where delegation is expected to provide no advantage over making the change directly, and its purpose is to check that the harness does not manufacture an advantage where none exists. The small library in this folder is a numeric range helper exported from range.js. The clamp function takes a value, a minimum, and a maximum, and it should return the minimum when the value falls below it and the maximum when the value falls above it, leaving a value already between them unchanged. With a minimum of 0 and a maximum of 10, clamp(-5, 0, 10) should return 0 and clamp(15, 0, 10) should return 10. In the current code the two boundaries are swapped, so clamp(-5, 0, 10) returns 10 and clamp(15, 0, 10) returns 0, pushing a value below the range to the top of the range and a value above the range to the bottom. The normalize function calls clamp internally and maps the result onto the 0 to 1 interval, so the same swapped boundaries make normalize(-5, 0, 10) return 1 instead of 0 and normalize(15, 0, 10) return 0 instead of 1. Please fix clamp so each out of range value returns the boundary on its own side, while keeping normalize, lerp, and the existing guard against a minimum greater than a maximum unchanged. diff --git a/bench/tasks/T02-negative-control/fixtures/package.json b/bench/tasks/T02-negative-control/fixtures/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T02-negative-control/fixtures/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T02-negative-control/fixtures/range.js b/bench/tasks/T02-negative-control/fixtures/range.js new file mode 100644 index 0000000..4d5811e --- /dev/null +++ b/bench/tasks/T02-negative-control/fixtures/range.js @@ -0,0 +1,23 @@ +export function clamp(value, min, max) { + if (min > max) { + throw new RangeError("min must not exceed max"); + } + if (value < min) { + return max; + } + if (value > max) { + return min; + } + return value; +} + +export function normalize(value, min, max) { + if (max === min) { + throw new RangeError("max must be greater than min"); + } + return (clamp(value, min, max) - min) / (max - min); +} + +export function lerp(t, min, max) { + return min + t * (max - min); +} diff --git a/bench/tasks/T02-negative-control/mutant/package.json b/bench/tasks/T02-negative-control/mutant/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T02-negative-control/mutant/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T02-negative-control/mutant/range.js b/bench/tasks/T02-negative-control/mutant/range.js new file mode 100644 index 0000000..4d5811e --- /dev/null +++ b/bench/tasks/T02-negative-control/mutant/range.js @@ -0,0 +1,23 @@ +export function clamp(value, min, max) { + if (min > max) { + throw new RangeError("min must not exceed max"); + } + if (value < min) { + return max; + } + if (value > max) { + return min; + } + return value; +} + +export function normalize(value, min, max) { + if (max === min) { + throw new RangeError("max must be greater than min"); + } + return (clamp(value, min, max) - min) / (max - min); +} + +export function lerp(t, min, max) { + return min + t * (max - min); +} diff --git a/bench/tasks/T02-negative-control/range.test.mjs b/bench/tasks/T02-negative-control/range.test.mjs new file mode 100644 index 0000000..16c1dd2 --- /dev/null +++ b/bench/tasks/T02-negative-control/range.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { clamp, normalize, lerp } from "./range.js"; + +test("clamp pins a low value to min and a high value to max", () => { + assert.equal(clamp(-5, 0, 10), 0); + assert.equal(clamp(15, 0, 10), 10); + assert.equal(clamp(4, 0, 10), 4); +}); + +test("clamp leaves boundary values unchanged", () => { + assert.equal(clamp(0, 0, 10), 0); + assert.equal(clamp(10, 0, 10), 10); +}); + +test("clamp rejects a range where min exceeds max", () => { + assert.throws(() => clamp(5, 10, 0), RangeError); +}); + +test("normalize maps a bounded value onto zero through one", () => { + assert.equal(normalize(0, 0, 10), 0); + assert.equal(normalize(10, 0, 10), 1); + assert.equal(normalize(5, 0, 10), 0.5); + assert.equal(normalize(-5, 0, 10), 0); + assert.equal(normalize(15, 0, 10), 1); +}); + +test("lerp is unaffected by the clamp fix", () => { + assert.equal(lerp(0, 0, 10), 0); + assert.equal(lerp(1, 0, 10), 10); + assert.equal(lerp(0.5, 0, 10), 5); +}); diff --git a/bench/tasks/T02-negative-control/selftest.sh b/bench/tasks/T02-negative-control/selftest.sh new file mode 100755 index 0000000..988685e --- /dev/null +++ b/bench/tasks/T02-negative-control/selftest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${TASK_DIR}/verify.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +TMP_MUTANT="$(mktemp -d)" +trap 'rm -rf "$TMP_MUTANT" "$TMP_SOLUTION"' EXIT +TMP_SOLUTION="$(mktemp -d)" + +cp -R "${TASK_DIR}/mutant"/. "$TMP_MUTANT"/ +if bash "$VERIFY" "$TMP_MUTANT"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "${TASK_DIR}/solution"/. "$TMP_SOLUTION"/ +if ! bash "$VERIFY" "$TMP_SOLUTION"; then + fail "solution leg: verify.sh should accept the reference fix" +fi + +echo "PASS" diff --git a/bench/tasks/T02-negative-control/solution/package.json b/bench/tasks/T02-negative-control/solution/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T02-negative-control/solution/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T02-negative-control/solution/range.js b/bench/tasks/T02-negative-control/solution/range.js new file mode 100644 index 0000000..0eb6321 --- /dev/null +++ b/bench/tasks/T02-negative-control/solution/range.js @@ -0,0 +1,23 @@ +export function clamp(value, min, max) { + if (min > max) { + throw new RangeError("min must not exceed max"); + } + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +export function normalize(value, min, max) { + if (max === min) { + throw new RangeError("max must be greater than min"); + } + return (clamp(value, min, max) - min) / (max - min); +} + +export function lerp(t, min, max) { + return min + t * (max - min); +} diff --git a/bench/tasks/T02-negative-control/verify.sh b/bench/tasks/T02-negative-control/verify.sh new file mode 100755 index 0000000..f037c58 --- /dev/null +++ b/bench/tasks/T02-negative-control/verify.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: bash verify.sh " >&2 + exit 2 +fi + +WORKDIR="$1" +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HIDDEN_TEST="${TASK_DIR}/range.test.mjs" + +if [[ ! -d "$WORKDIR" ]]; then + echo "workdir is not a directory: $WORKDIR" >&2 + exit 2 +fi + +if [[ ! -f "$HIDDEN_TEST" ]]; then + echo "hidden test missing: $HIDDEN_TEST" >&2 + exit 2 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +cp -R "$WORKDIR"/. "$TMP"/ +cp "$HIDDEN_TEST" "$TMP"/ + +cd "$TMP" +exec node --test diff --git a/bench/tasks/T03-spec-implementation/brief.md b/bench/tasks/T03-spec-implementation/brief.md new file mode 100644 index 0000000..4840458 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/brief.md @@ -0,0 +1,12 @@ +# Event bus implementation + +Implement the event bus described here in the fixture project. The package has no dependencies and uses native Node ESM. + +The package entry point, `index.js`, must export the named exports `EventBus` and `matchesPattern`. Keep the implementation split between `match-pattern.js`, `subscriptions.js`, and `event-bus.js`; `index.js` only exposes the public API. + +1. A topic is a nonempty string of dot separated segments. Each segment must be nonempty and must not contain `.` or `*`. A pattern follows the same rule except its final segment may be `*`. A pattern of `*` is valid. Reject every invalid topic or pattern with `TypeError`. +2. `matchesPattern(pattern, topic)` validates both arguments. It returns `true` for an exact match. A final `*` matches exactly one topic segment, so `orders.*` matches `orders.created` but not `orders.created.audit`, and `*` matches `ready` but not `system.ready`. +3. `new EventBus()` creates an empty bus. `subscribe(pattern, listener)` validates its pattern and requires a function listener, otherwise it throws `TypeError`. It returns an unsubscribe function. Calling that function removes only its registration and returns `true`; later calls return `false`. Separate registrations of the same listener are separate subscriptions. +4. `once(pattern, listener)` has the same validation and return contract as `subscribe`. Its listener is removed before it is called, so a listener that publishes the same topic recursively still runs only once. +5. `publish(topic, payload)` validates its topic, then synchronously calls every listener whose pattern matches. Listeners receive `(payload, topic)` and run in registration order across exact and wildcard patterns. It returns the number of listeners selected for the delivery. +6. A publish uses a snapshot of the matching registrations taken before the first listener runs. A listener that subscribes or unsubscribes during delivery does not change that delivery, but does affect later publishes. If a listener throws, `publish` propagates that error immediately and does not call later listeners. diff --git a/bench/tasks/T03-spec-implementation/event-bus.test.mjs b/bench/tasks/T03-spec-implementation/event-bus.test.mjs new file mode 100644 index 0000000..f2658b7 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/event-bus.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { EventBus, matchesPattern } from "./index.js"; + +test("the package exports the public event bus API", () => { + assert.equal(typeof EventBus, "function"); + assert.equal(typeof matchesPattern, "function"); +}); + +test("patterns match exact topics and one wildcard segment", () => { + assert.equal(matchesPattern("orders.created", "orders.created"), true); + assert.equal(matchesPattern("orders.*", "orders.created"), true); + assert.equal(matchesPattern("*", "ready"), true); + assert.equal(matchesPattern("orders.*", "orders.created.audit"), false); + assert.equal(matchesPattern("*", "system.ready"), false); + assert.equal(matchesPattern("orders.*", "payments.created"), false); +}); + +test("topics and patterns reject invalid names", () => { + const invalidTopics = ["", ".ready", "ready.", "system..ready", "system.*", 3]; + const invalidPatterns = ["", ".ready", "ready.", "system..*", "*.ready", "ready*", 3]; + + for (const topic of invalidTopics) { + assert.throws(() => matchesPattern("*", topic), TypeError); + } + for (const pattern of invalidPatterns) { + assert.throws(() => matchesPattern(pattern, "ready"), TypeError); + } +}); + +test("publish delivers matching listeners in registration order and preserves payload identity", () => { + const bus = new EventBus(); + const payload = { id: 42 }; + const received = []; + + bus.subscribe("orders.*", (value, topic) => received.push(["wildcard", value, topic])); + bus.subscribe("orders.created", (value, topic) => received.push(["exact", value, topic])); + bus.subscribe("orders.*", (value, topic) => received.push(["second wildcard", value, topic])); + + assert.equal(bus.publish("orders.created", payload), 3); + assert.deepEqual(received, [ + ["wildcard", payload, "orders.created"], + ["exact", payload, "orders.created"], + ["second wildcard", payload, "orders.created"], + ]); +}); + +test("separate subscriptions and unsubscription have independent idempotent results", () => { + const bus = new EventBus(); + let calls = 0; + const listener = () => { + calls += 1; + }; + const removeFirst = bus.subscribe("ready", listener); + const removeSecond = bus.subscribe("ready", listener); + + assert.equal(removeFirst(), true); + assert.equal(removeFirst(), false); + assert.equal(bus.publish("ready"), 1); + assert.equal(calls, 1); + assert.equal(removeSecond(), true); + assert.equal(bus.publish("ready"), 0); +}); + +test("once removes its listener before a recursive publish", () => { + const bus = new EventBus(); + let calls = 0; + const removeUncalled = bus.once("later", () => { + calls += 1; + }); + const remove = bus.once("tick", () => { + calls += 1; + assert.equal(bus.publish("tick"), 0); + }); + + assert.equal(removeUncalled(), true); + assert.equal(removeUncalled(), false); + assert.equal(bus.publish("later"), 0); + assert.equal(bus.publish("tick"), 1); + assert.equal(calls, 1); + assert.equal(remove(), false); + assert.equal(bus.publish("tick"), 0); +}); + +test("subscriptions changed during publish affect only later publishes", () => { + const bus = new EventBus(); + const received = []; + let removeThird; + + bus.subscribe("sync", () => { + received.push("first"); + removeThird(); + bus.subscribe("sync", () => received.push("later")); + }); + bus.subscribe("sync", () => received.push("second")); + removeThird = bus.subscribe("sync", () => received.push("third")); + + assert.equal(bus.publish("sync"), 3); + assert.deepEqual(received, ["first", "second", "third"]); + received.length = 0; + assert.equal(bus.publish("sync"), 3); + assert.deepEqual(received, ["first", "second", "later"]); +}); + +test("validation failures and listener errors stop publishing", () => { + const bus = new EventBus(); + assert.throws(() => bus.subscribe("ready", null), TypeError); + assert.throws(() => bus.once("ready", null), TypeError); + assert.throws(() => bus.subscribe("*.ready", () => {}), TypeError); + assert.throws(() => bus.publish("ready.*"), TypeError); + + const failure = new Error("listener failure"); + let afterFailure = false; + bus.subscribe("fail", () => { + throw failure; + }); + bus.subscribe("fail", () => { + afterFailure = true; + }); + + assert.throws(() => bus.publish("fail"), failure); + assert.equal(afterFailure, false); +}); diff --git a/bench/tasks/T03-spec-implementation/fixtures/event-bus.js b/bench/tasks/T03-spec-implementation/fixtures/event-bus.js new file mode 100644 index 0000000..44ba697 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/fixtures/event-bus.js @@ -0,0 +1,13 @@ +export class EventBus { + subscribe() { + throw new Error("not implemented"); + } + + once() { + throw new Error("not implemented"); + } + + publish() { + throw new Error("not implemented"); + } +} diff --git a/bench/tasks/T03-spec-implementation/fixtures/index.js b/bench/tasks/T03-spec-implementation/fixtures/index.js new file mode 100644 index 0000000..5482630 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/fixtures/index.js @@ -0,0 +1,2 @@ +export { EventBus } from "./event-bus.js"; +export { matchesPattern } from "./match-pattern.js"; diff --git a/bench/tasks/T03-spec-implementation/fixtures/match-pattern.js b/bench/tasks/T03-spec-implementation/fixtures/match-pattern.js new file mode 100644 index 0000000..caa0e2b --- /dev/null +++ b/bench/tasks/T03-spec-implementation/fixtures/match-pattern.js @@ -0,0 +1,17 @@ +export function assertTopic(topic) { + if (typeof topic !== "string") { + throw new TypeError("topic must be a string"); + } +} + +export function assertPattern(pattern) { + if (typeof pattern !== "string") { + throw new TypeError("pattern must be a string"); + } +} + +export function matchesPattern(pattern, topic) { + assertPattern(pattern); + assertTopic(topic); + return pattern === topic; +} diff --git a/bench/tasks/T03-spec-implementation/fixtures/package.json b/bench/tasks/T03-spec-implementation/fixtures/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T03-spec-implementation/fixtures/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T03-spec-implementation/fixtures/subscriptions.js b/bench/tasks/T03-spec-implementation/fixtures/subscriptions.js new file mode 100644 index 0000000..c0d6d0c --- /dev/null +++ b/bench/tasks/T03-spec-implementation/fixtures/subscriptions.js @@ -0,0 +1,15 @@ +import { matchesPattern } from "./match-pattern.js"; + +export class SubscriptionList { + add(pattern, listener) { + return { pattern, listener }; + } + + remove() { + return false; + } + + matching(topic) { + return [].filter((subscription) => matchesPattern(subscription.pattern, topic)); + } +} diff --git a/bench/tasks/T03-spec-implementation/mutant/event-bus.js b/bench/tasks/T03-spec-implementation/mutant/event-bus.js new file mode 100644 index 0000000..0f6a25a --- /dev/null +++ b/bench/tasks/T03-spec-implementation/mutant/event-bus.js @@ -0,0 +1,47 @@ +import { assertPattern, assertTopic } from "./match-pattern.js"; +import { SubscriptionList } from "./subscriptions.js"; + +function assertListener(listener) { + if (typeof listener !== "function") { + throw new TypeError("listener must be a function"); + } +} + +export class EventBus { + #subscriptions = new SubscriptionList(); + + subscribe(pattern, listener) { + assertPattern(pattern); + assertListener(listener); + const subscription = this.#subscriptions.add(pattern, listener); + let subscribed = true; + return () => { + if (!subscribed) { + return false; + } + subscribed = false; + return this.#subscriptions.remove(subscription); + }; + } + + once(pattern, listener) { + assertPattern(pattern); + assertListener(listener); + let unsubscribe; + const wrappedListener = (payload, topic) => { + unsubscribe(); + listener(payload, topic); + }; + unsubscribe = this.subscribe(pattern, wrappedListener); + return unsubscribe; + } + + publish(topic, payload) { + assertTopic(topic); + const subscriptions = this.#subscriptions.matching(topic); + for (const subscription of subscriptions) { + subscription.listener(payload, topic); + } + return subscriptions.length; + } +} diff --git a/bench/tasks/T03-spec-implementation/mutant/index.js b/bench/tasks/T03-spec-implementation/mutant/index.js new file mode 100644 index 0000000..5482630 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/mutant/index.js @@ -0,0 +1,2 @@ +export { EventBus } from "./event-bus.js"; +export { matchesPattern } from "./match-pattern.js"; diff --git a/bench/tasks/T03-spec-implementation/mutant/match-pattern.js b/bench/tasks/T03-spec-implementation/mutant/match-pattern.js new file mode 100644 index 0000000..a11bbbb --- /dev/null +++ b/bench/tasks/T03-spec-implementation/mutant/match-pattern.js @@ -0,0 +1,39 @@ +function hasValidSegments(value, permitsWildcard) { + const segments = value.split("."); + return segments.every((segment, index) => { + if (segment.length === 0) { + return false; + } + if (segment === "*") { + return permitsWildcard && index === segments.length - 1; + } + return !segment.includes("*"); + }); +} + +export function assertTopic(topic) { + if (typeof topic !== "string" || !hasValidSegments(topic, false)) { + throw new TypeError("topic must be a valid topic"); + } +} + +export function assertPattern(pattern) { + if (typeof pattern !== "string" || !hasValidSegments(pattern, true)) { + throw new TypeError("pattern must be a valid pattern"); + } +} + +export function matchesPattern(pattern, topic) { + assertPattern(pattern); + assertTopic(topic); + if (pattern === topic) { + return true; + } + if (pattern === "*") { + return !topic.includes("."); + } + if (!pattern.endsWith(".*")) { + return false; + } + return topic.startsWith(pattern.slice(0, -1)); +} diff --git a/bench/tasks/T03-spec-implementation/mutant/package.json b/bench/tasks/T03-spec-implementation/mutant/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T03-spec-implementation/mutant/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T03-spec-implementation/mutant/subscriptions.js b/bench/tasks/T03-spec-implementation/mutant/subscriptions.js new file mode 100644 index 0000000..034f93d --- /dev/null +++ b/bench/tasks/T03-spec-implementation/mutant/subscriptions.js @@ -0,0 +1,29 @@ +import { matchesPattern } from "./match-pattern.js"; + +export class SubscriptionList { + #subscriptions = []; + + add(pattern, listener) { + const subscription = { pattern, listener, active: true }; + this.#subscriptions.push(subscription); + return subscription; + } + + remove(subscription) { + if (!subscription.active) { + return false; + } + subscription.active = false; + const index = this.#subscriptions.indexOf(subscription); + if (index !== -1) { + this.#subscriptions.splice(index, 1); + } + return true; + } + + matching(topic) { + return this.#subscriptions.filter( + (subscription) => subscription.active && matchesPattern(subscription.pattern, topic), + ); + } +} diff --git a/bench/tasks/T03-spec-implementation/selftest.sh b/bench/tasks/T03-spec-implementation/selftest.sh new file mode 100755 index 0000000..c1becb3 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/selftest.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +task_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +verify=$task_dir/verify.sh + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +tmp_mutant=$(mktemp -d) +tmp_solution=$(mktemp -d) +trap 'rm -rf "$tmp_mutant" "$tmp_solution"' EXIT HUP INT TERM + +cp -R "$task_dir"/mutant/. "$tmp_mutant"/ +if sh "$verify" "$tmp_mutant"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "$task_dir"/solution/. "$tmp_solution"/ +if ! sh "$verify" "$tmp_solution"; then + fail "solution leg: verify.sh should accept the reference implementation" +fi + +echo "PASS" diff --git a/bench/tasks/T03-spec-implementation/solution/event-bus.js b/bench/tasks/T03-spec-implementation/solution/event-bus.js new file mode 100644 index 0000000..0f6a25a --- /dev/null +++ b/bench/tasks/T03-spec-implementation/solution/event-bus.js @@ -0,0 +1,47 @@ +import { assertPattern, assertTopic } from "./match-pattern.js"; +import { SubscriptionList } from "./subscriptions.js"; + +function assertListener(listener) { + if (typeof listener !== "function") { + throw new TypeError("listener must be a function"); + } +} + +export class EventBus { + #subscriptions = new SubscriptionList(); + + subscribe(pattern, listener) { + assertPattern(pattern); + assertListener(listener); + const subscription = this.#subscriptions.add(pattern, listener); + let subscribed = true; + return () => { + if (!subscribed) { + return false; + } + subscribed = false; + return this.#subscriptions.remove(subscription); + }; + } + + once(pattern, listener) { + assertPattern(pattern); + assertListener(listener); + let unsubscribe; + const wrappedListener = (payload, topic) => { + unsubscribe(); + listener(payload, topic); + }; + unsubscribe = this.subscribe(pattern, wrappedListener); + return unsubscribe; + } + + publish(topic, payload) { + assertTopic(topic); + const subscriptions = this.#subscriptions.matching(topic); + for (const subscription of subscriptions) { + subscription.listener(payload, topic); + } + return subscriptions.length; + } +} diff --git a/bench/tasks/T03-spec-implementation/solution/index.js b/bench/tasks/T03-spec-implementation/solution/index.js new file mode 100644 index 0000000..5482630 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/solution/index.js @@ -0,0 +1,2 @@ +export { EventBus } from "./event-bus.js"; +export { matchesPattern } from "./match-pattern.js"; diff --git a/bench/tasks/T03-spec-implementation/solution/match-pattern.js b/bench/tasks/T03-spec-implementation/solution/match-pattern.js new file mode 100644 index 0000000..1d7af52 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/solution/match-pattern.js @@ -0,0 +1,37 @@ +function hasValidSegments(value, permitsWildcard) { + const segments = value.split("."); + return segments.every((segment, index) => { + if (segment.length === 0) { + return false; + } + if (segment === "*") { + return permitsWildcard && index === segments.length - 1; + } + return !segment.includes("*"); + }); +} + +export function assertTopic(topic) { + if (typeof topic !== "string" || !hasValidSegments(topic, false)) { + throw new TypeError("topic must be a valid topic"); + } +} + +export function assertPattern(pattern) { + if (typeof pattern !== "string" || !hasValidSegments(pattern, true)) { + throw new TypeError("pattern must be a valid pattern"); + } +} + +export function matchesPattern(pattern, topic) { + assertPattern(pattern); + assertTopic(topic); + const patternSegments = pattern.split("."); + const topicSegments = topic.split("."); + if (patternSegments.length !== topicSegments.length) { + return false; + } + return patternSegments.every( + (segment, index) => segment === "*" || segment === topicSegments[index], + ); +} diff --git a/bench/tasks/T03-spec-implementation/solution/package.json b/bench/tasks/T03-spec-implementation/solution/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T03-spec-implementation/solution/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T03-spec-implementation/solution/subscriptions.js b/bench/tasks/T03-spec-implementation/solution/subscriptions.js new file mode 100644 index 0000000..034f93d --- /dev/null +++ b/bench/tasks/T03-spec-implementation/solution/subscriptions.js @@ -0,0 +1,29 @@ +import { matchesPattern } from "./match-pattern.js"; + +export class SubscriptionList { + #subscriptions = []; + + add(pattern, listener) { + const subscription = { pattern, listener, active: true }; + this.#subscriptions.push(subscription); + return subscription; + } + + remove(subscription) { + if (!subscription.active) { + return false; + } + subscription.active = false; + const index = this.#subscriptions.indexOf(subscription); + if (index !== -1) { + this.#subscriptions.splice(index, 1); + } + return true; + } + + matching(topic) { + return this.#subscriptions.filter( + (subscription) => subscription.active && matchesPattern(subscription.pattern, topic), + ); + } +} diff --git a/bench/tasks/T03-spec-implementation/verify.sh b/bench/tasks/T03-spec-implementation/verify.sh new file mode 100755 index 0000000..c91d308 --- /dev/null +++ b/bench/tasks/T03-spec-implementation/verify.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: sh verify.sh " >&2 + exit 2 +fi + +workdir=$1 +task_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +hidden_test=$task_dir/event-bus.test.mjs + +if [ ! -d "$workdir" ]; then + echo "workdir is not a directory: $workdir" >&2 + exit 2 +fi + +if [ ! -f "$hidden_test" ]; then + echo "hidden test missing: $hidden_test" >&2 + exit 2 +fi + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +cp -R "$workdir"/. "$tmp_dir"/ +cp "$hidden_test" "$tmp_dir"/ + +cd "$tmp_dir" +exec node --test diff --git a/bench/tasks/T04-checkable-research/brief.md b/bench/tasks/T04-checkable-research/brief.md new file mode 100644 index 0000000..6fb93f5 --- /dev/null +++ b/bench/tasks/T04-checkable-research/brief.md @@ -0,0 +1,15 @@ +The corpus folder in this task's fixtures holds documentation for Emberflow, a fictional workflow automation engine that reads its settings from an emberflow.yml file. The documents mix full changelogs, standalone release notes for individual versions, a configuration reference, a migration guide, a deprecation notice, a frequently asked questions page, a known issues list, and a support ticket digest, together covering the project's entire release history from its first stable version through its current version. Several of these documents state a configuration default that was accurate when that document was written but was changed again in a later release, so finding a value for a key in one document does not mean that value is still current; read enough of the corpus to confirm which value for each key the current version actually ships, not merely a value the key has held at some point in its history. + +Answer the following question about three settings read from emberflow.yml: queue.concurrency, retry.backoff_ms, and webhook.timeout_ms. For each of the three keys, determine the value it currently defaults to in the latest version covered by this corpus, and the version in which that current value was most recently set. A key's current value was most recently set in the latest version whose changelog entry changes that key, even when later versions have shipped since without touching it; for example, if a key was set to 10 in version 1.0.0 and no later version ever changes it again, then 10 from version 1.0.0 remains both its current value and the version of its most recent change, no matter how many later versions have shipped since. + +Write your answer as a single JSON file named answer.json in the root of your working directory, containing nothing but that JSON. Only answer.json is graded; you may leave other working files in place if that helps you, but nothing else is read. Use exactly the shape below, with exactly these three top level keys spelled exactly as shown, each holding an object with exactly two fields: final_default as a JSON number, not a string, and changed_in_version as a JSON string holding a plain three part version number with no leading v, matching the version headings used in this corpus's changelogs, for example "4.0.0". + +```json +{ + "queue_concurrency": { "final_default": 0, "changed_in_version": "0.0.0" }, + "retry_backoff_ms": { "final_default": 0, "changed_in_version": "0.0.0" }, + "webhook_timeout_ms": { "final_default": 0, "changed_in_version": "0.0.0" } +} +``` + +The zeroes above are placeholders that show the required shape only; replace every final_default and every changed_in_version with the value you determine from the corpus, and do not otherwise change the shape. diff --git a/bench/tasks/T04-checkable-research/check-answer.mjs b/bench/tasks/T04-checkable-research/check-answer.mjs new file mode 100755 index 0000000..27845cf --- /dev/null +++ b/bench/tasks/T04-checkable-research/check-answer.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import fs from "node:fs"; + +const [, , answerPath] = process.argv; + +if (!answerPath) { + console.error("usage: node check-answer.mjs "); + process.exit(2); +} + +const EXPECTED = { + queue_concurrency: { final_default: 12, changed_in_version: "4.0.0" }, + retry_backoff_ms: { final_default: 900, changed_in_version: "4.2.0" }, + webhook_timeout_ms: { final_default: 8000, changed_in_version: "4.1.0" } +}; + +if (!fs.existsSync(answerPath)) { + console.error(`answer file missing: ${answerPath}`); + process.exit(1); +} + +let parsed; +try { + parsed = JSON.parse(fs.readFileSync(answerPath, "utf8")); +} catch (err) { + console.error(`answer file is not valid JSON: ${err.message}`); + process.exit(1); +} + +let ok = true; +for (const [key, expected] of Object.entries(EXPECTED)) { + const actual = parsed && typeof parsed === "object" ? parsed[key] : undefined; + if (!actual || typeof actual !== "object") { + console.error(`${key}: missing or not an object`); + ok = false; + continue; + } + if (actual.final_default !== expected.final_default) { + console.error(`${key}.final_default: expected ${expected.final_default}, got ${JSON.stringify(actual.final_default)}`); + ok = false; + } + if (actual.changed_in_version !== expected.changed_in_version) { + console.error(`${key}.changed_in_version: expected ${JSON.stringify(expected.changed_in_version)}, got ${JSON.stringify(actual.changed_in_version)}`); + ok = false; + } +} + +if (!ok) { + process.exit(1); +} + +console.log("answer.json matches the expected research result."); diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v1-v2.md b/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v1-v2.md new file mode 100644 index 0000000..26f89ae --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v1-v2.md @@ -0,0 +1,19 @@ +# Emberflow changelog: v1.0.0 through v2.3.0 + +This file covers the earliest Emberflow releases, from the first stable release through the rest of the v2 series. The v3 series continues in changelog-v3.md, and the v4 series, which is the current series as of this corpus, continues after that in changelog-v4.md. + +## v1.0.0, released 2024-01-10 + +The first stable release of Emberflow. Its shipped defaults are queue.concurrency at 4, retry.backoff_ms at 500, and webhook.timeout_ms at 2000. These were chosen conservatively for small deployments and were expected to be tuned upward as real world usage came in. + +## v1.4.0, released 2024-04-02 + +Raises the default retry.backoff_ms from 500 to 750. Early adopters running high volume queues reported retries landing in tight clusters right after a failure, and the wider backoff spreads them out. + +## v2.0.0, released 2024-07-15 + +A significant release. The default queue.concurrency moves from 4 to 8 to take advantage of multi core hosts, and the default webhook.timeout_ms moves from 2000 to 5000 after reports of slow downstream endpoints being cut off before they could respond. See release-notes-v2.0.0.md in this corpus for the full rationale behind both changes. + +## v2.3.0, released 2024-09-20 + +Raises the default retry.backoff_ms again, from 750 to 1000, as part of a broader retry tuning pass ahead of planning for the v3 series. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v3.md b/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v3.md new file mode 100644 index 0000000..8cd55fd --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v3.md @@ -0,0 +1,15 @@ +# Emberflow changelog: v3.0.0-beta.1 through v3.2.0 + +This file continues from changelog-v1-v2.md and covers the v3 series. The v4 series is covered separately in changelog-v4.md. + +## v3.0.0-beta.1, released 2024-11-01 + +An experimental beta offered for feedback ahead of the v3.0.0 stable release. This beta proposes raising the default webhook.timeout_ms from 5000 to 10000, to cut down further on premature timeouts against slow endpoints. The proposal is flagged experimental in the beta notes and is never promoted to a stable release; see known-issues.md for why it was withdrawn before v3.0.0 shipped. + +## v3.0.0, released 2025-01-08 + +The first stable release in the v3 series. Based on feedback gathered during the beta, the default queue.concurrency is lowered from 8 to 6 to improve scheduling stability on smaller hosts. The webhook.timeout_ms proposal from v3.0.0-beta.1 is reverted for this stable release, so webhook.timeout_ms stays at 5000, unchanged from v2.0.0. + +## v3.2.0, released 2025-03-14 + +A bug fix release. The default retry.backoff_ms is raised from 1000 to 1200 after telemetry showed retries still bunching together under sustained load. queue.concurrency and webhook.timeout_ms are unchanged from v3.0.0 in this release. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v4.md b/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v4.md new file mode 100644 index 0000000..ab760bb --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/changelog-v4.md @@ -0,0 +1,15 @@ +# Emberflow changelog: v4.0.0 through v4.2.0, current series + +This file continues from changelog-v3.md. v4.2.0 is the latest version covered by this corpus and is the current release. + +## v4.0.0, released 2025-06-30 + +A major release built around a new scheduler. This release raises the default queue.concurrency to take advantage of the new scheduler; see migration-guide-v3-to-v4.md for the exact new value and for guidance on tuning it further on larger hosts. retry.backoff_ms and webhook.timeout_ms are unaffected by this release. + +## v4.1.0, released 2025-08-18 + +Raises the default webhook.timeout_ms to better support the slow downstream endpoints that several large deployments reported. See release-notes-v4.1.0.md for the exact new value and the supporting data. queue.concurrency and retry.backoff_ms are unchanged from v4.0.0. + +## v4.2.0, released 2025-10-05, current release + +Lowers the default retry.backoff_ms to fix a regression introduced by the v3.2.0 tuning pass, which had overcorrected and left retries waiting longer than necessary after failures that clear quickly. See deprecation-notice.md for the exact new value and further background. queue.concurrency and webhook.timeout_ms are unchanged from v4.1.0. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/config-reference.md b/bench/tasks/T04-checkable-research/fixtures/corpus/config-reference.md new file mode 100644 index 0000000..7f446de --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/config-reference.md @@ -0,0 +1,9 @@ +# Emberflow configuration reference + +This reference lists settings read from emberflow.yml along with their default values. It was last revised for the v3.2.0 release; consult the changelog for any default that changed in a later release. + +- queue.concurrency: number of worker threads processing jobs at once. Default as of this revision: 6. +- retry.backoff_ms: milliseconds to wait before retrying a failed job. Default as of this revision: 1200. +- webhook.timeout_ms: milliseconds to wait for a webhook call to respond before it is treated as failed. Default as of this revision: 5000. +- log.level: logging verbosity, one of quiet, normal, or verbose. Default: normal, unchanged since v1.0.0. +- scheduler.mode: internal scheduling strategy, one of fifo or priority. Default: fifo, unchanged since v1.0.0. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/deprecation-notice.md b/bench/tasks/T04-checkable-research/fixtures/corpus/deprecation-notice.md new file mode 100644 index 0000000..f7e8793 --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/deprecation-notice.md @@ -0,0 +1,11 @@ +# Emberflow deprecation and regression notice, v4.2.0 + +Released 2025-10-05. + +## Retry backoff regression fix + +The v3.2.0 release raised the default retry.backoff_ms from 1000 to 1200 to address retries bunching together under sustained load. Telemetry gathered over the following two releases showed this went too far: jobs failing for reasons that clear up quickly, such as a brief network blip, ended up waiting longer than necessary before retrying. This release lowers the default retry.backoff_ms from 1200 to 900, a value telemetry shows keeps retries spread out during sustained load without punishing quick recoveries too harshly. + +## Deprecated: retry.jitter_legacy + +The retry.jitter_legacy flag, which toggled an older jitter algorithm, is deprecated as of this release and will be removed in v5.0.0. New deployments should not set it; the current retry.backoff_ms default already accounts for jitter internally. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/faq.md b/bench/tasks/T04-checkable-research/fixtures/corpus/faq.md new file mode 100644 index 0000000..8d922fa --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/faq.md @@ -0,0 +1,17 @@ +# Emberflow frequently asked questions + +## What does queue.concurrency control? + +It controls how many worker threads process jobs at the same time. Raising it lets more jobs run in parallel at the cost of more memory and CPU use per host. + +## Is 1200 milliseconds still the default retry backoff? + +This question comes up often because 1200 milliseconds was the default introduced in v3.2.0 and is still what many teams remember. Always check the changelog for the release actually running in your deployment, since this default has changed more than once since v3.2.0. + +## Can retries be disabled entirely? + +Yes, set retry.backoff_ms to 0 and retry.max_attempts to 1. This is not recommended for production deployments. + +## Does Emberflow support cron style schedules? + +Yes, a job definition's schedule key accepts a standard five field cron expression. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/known-issues.md b/bench/tasks/T04-checkable-research/fixtures/corpus/known-issues.md new file mode 100644 index 0000000..7c1f00d --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/known-issues.md @@ -0,0 +1,9 @@ +# Emberflow known issues + +## Resolved: webhook timeout experiment reverted before v3.0.0 + +The v3.0.0-beta.1 build shipped with an experimental webhook.timeout_ms default of 10000, meant to cut down even further on premature timeouts beyond the v2.0.0 default of 5000. Beta testers found that connection pools on some downstream services became exhausted when many slow calls were held open at once at the longer timeout. The experiment was withdrawn, and the v3.0.0 stable release shipped with webhook.timeout_ms unchanged at 5000. The default was revisited again later, in v4.1.0; see release-notes-v4.1.0.md. + +## Open: emberflow doctor misreports scheduler.mode right after a config reload + +Running emberflow doctor immediately after emberflow reload can print the previous scheduler.mode value for a few seconds before the cache refreshes. Rerunning the command shows the correct value. Tracked for a fix in an upcoming release. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/migration-guide-v3-to-v4.md b/bench/tasks/T04-checkable-research/fixtures/corpus/migration-guide-v3-to-v4.md new file mode 100644 index 0000000..2ed98d3 --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/migration-guide-v3-to-v4.md @@ -0,0 +1,15 @@ +# Emberflow migration guide: v3.x to v4.0.0 + +This guide helps teams upgrading from any v3.x release to v4.0.0. + +## Scheduler rewrite + +v4.0.0 replaces the old fifo scheduler with a new engine that spreads jobs across worker threads more evenly under uneven workloads. As part of this rewrite, the default queue.concurrency changes from 6, the v3.0.0 stable value, to 12. Most deployments should leave this at the new default. Teams running Emberflow on hosts with more than 64 gigabytes of memory available to the process may consider manually raising queue.concurrency to 16, but 12 is the value v4.0.0 ships as its default for every other host. + +## Retry and webhook settings + +This migration does not touch retry.backoff_ms or webhook.timeout_ms. Both keys keep whatever value they held under v3.2.0 until a later release changes them; see changelog-v4.md for when that happens. + +## Removed keys + +The legacy worker.threads key, deprecated since v2.0.0 in favor of queue.concurrency, is removed entirely in v4.0.0. Any emberflow.yml still setting worker.threads should have that line deleted; the key is silently ignored otherwise. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v2.0.0.md b/bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v2.0.0.md new file mode 100644 index 0000000..dee584a --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v2.0.0.md @@ -0,0 +1,15 @@ +# Emberflow v2.0.0 release notes + +Released 2024-07-15. + +## Concurrency + +The default queue.concurrency moves from 4 to 8. Hosts with more than one core were leaving capacity unused under the v1 defaults, and 8 was chosen as a value that keeps memory use reasonable on the smallest hosts Emberflow supports while still using multiple cores. + +## Webhook timeout + +The default webhook.timeout_ms moves from 2000 to 5000. Reports came in of downstream endpoints, particularly ones waking from a cold start, taking longer than 2 seconds to respond even though they eventually succeeded. 5000 milliseconds gives those endpoints enough room without waiting indefinitely. + +## Looking ahead + +Both values are expected to keep moving as more production data comes in; see later release notes and the changelog for what changes next. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v4.1.0.md b/bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v4.1.0.md new file mode 100644 index 0000000..aa0c269 --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/release-notes-v4.1.0.md @@ -0,0 +1,11 @@ +# Emberflow v4.1.0 release notes + +Released 2025-08-18. + +## Webhook timeout increase + +Several large deployments reported webhook calls to slow internal endpoints being cut off before they could respond, even with the timeout that v3.0.0 kept at 5000 milliseconds. This release raises the default webhook.timeout_ms from 5000 to 8000. Deployments whose webhook endpoints already respond quickly should see no behavior change; deployments with slower endpoints should see fewer spurious timeout failures. + +## Other changes + +This release does not change queue.concurrency or retry.backoff_ms. See changelog-v4.md for the full v4.1.0 entry. diff --git a/bench/tasks/T04-checkable-research/fixtures/corpus/support-ticket-digest.md b/bench/tasks/T04-checkable-research/fixtures/corpus/support-ticket-digest.md new file mode 100644 index 0000000..52564fd --- /dev/null +++ b/bench/tasks/T04-checkable-research/fixtures/corpus/support-ticket-digest.md @@ -0,0 +1,3 @@ +# Emberflow support ticket digest, week of 2025-03-17 + +A recurring theme this week: several tickets asked why retries seemed to take longer after upgrading to v3.2.0, which released three days before this digest. Support confirmed this is expected: v3.2.0 raised the default retry.backoff_ms from 1000 to 1200 milliseconds. One ticket also asked about worker thread counts; support noted that queue.concurrency has defaulted to 6 since v3.0.0 and confirmed the reporting customer had not overridden it in their own emberflow.yml. Both figures were correct for the release running at the time of this digest; teams reading it on a later release should confirm current defaults against the changelog, since both values have changed again since this digest was written. diff --git a/bench/tasks/T04-checkable-research/mutant/answer.json b/bench/tasks/T04-checkable-research/mutant/answer.json new file mode 100644 index 0000000..e6d239c --- /dev/null +++ b/bench/tasks/T04-checkable-research/mutant/answer.json @@ -0,0 +1,14 @@ +{ + "queue_concurrency": { + "final_default": 6, + "changed_in_version": "3.0.0" + }, + "retry_backoff_ms": { + "final_default": 1200, + "changed_in_version": "3.2.0" + }, + "webhook_timeout_ms": { + "final_default": 5000, + "changed_in_version": "2.0.0" + } +} diff --git a/bench/tasks/T04-checkable-research/selftest.sh b/bench/tasks/T04-checkable-research/selftest.sh new file mode 100755 index 0000000..988685e --- /dev/null +++ b/bench/tasks/T04-checkable-research/selftest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${TASK_DIR}/verify.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +TMP_MUTANT="$(mktemp -d)" +trap 'rm -rf "$TMP_MUTANT" "$TMP_SOLUTION"' EXIT +TMP_SOLUTION="$(mktemp -d)" + +cp -R "${TASK_DIR}/mutant"/. "$TMP_MUTANT"/ +if bash "$VERIFY" "$TMP_MUTANT"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "${TASK_DIR}/solution"/. "$TMP_SOLUTION"/ +if ! bash "$VERIFY" "$TMP_SOLUTION"; then + fail "solution leg: verify.sh should accept the reference fix" +fi + +echo "PASS" diff --git a/bench/tasks/T04-checkable-research/solution/answer.json b/bench/tasks/T04-checkable-research/solution/answer.json new file mode 100644 index 0000000..03c074c --- /dev/null +++ b/bench/tasks/T04-checkable-research/solution/answer.json @@ -0,0 +1,14 @@ +{ + "queue_concurrency": { + "final_default": 12, + "changed_in_version": "4.0.0" + }, + "retry_backoff_ms": { + "final_default": 900, + "changed_in_version": "4.2.0" + }, + "webhook_timeout_ms": { + "final_default": 8000, + "changed_in_version": "4.1.0" + } +} diff --git a/bench/tasks/T04-checkable-research/verify.sh b/bench/tasks/T04-checkable-research/verify.sh new file mode 100755 index 0000000..97ce39b --- /dev/null +++ b/bench/tasks/T04-checkable-research/verify.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: bash verify.sh " >&2 + exit 2 +fi + +WORKDIR="$1" +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECKER="${TASK_DIR}/check-answer.mjs" + +if [[ ! -d "$WORKDIR" ]]; then + echo "workdir is not a directory: $WORKDIR" >&2 + exit 2 +fi + +if [[ ! -f "$CHECKER" ]]; then + echo "checker script missing: $CHECKER" >&2 + exit 2 +fi + +exec node "$CHECKER" "${WORKDIR}/answer.json" diff --git a/bench/tasks/T05-mechanical-codemod/brief.md b/bench/tasks/T05-mechanical-codemod/brief.md new file mode 100644 index 0000000..353c23f --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/brief.md @@ -0,0 +1 @@ +This project is a small Node ESM package that exports nine text builders from index.js. Every builder imports formatWithCallback(value, callback) from format.js and wraps that callback based API in a Promise. Replace the legacy callback convention at every call site with await format(value), which returns a Promise containing the same normalized text. Update format.js so it exposes only the replacement convention, and make the import and async function changes needed by every builder. Preserve every exported function name, its returned text, and its asynchronous behavior. Do not add dependencies or change the public exports. diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/alert.js b/bench/tasks/T05-mechanical-codemod/fixtures/alert.js new file mode 100644 index 0000000..723a224 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/alert.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildAlert(message) { + return new Promise((resolve) => { + formatWithCallback(message, (value) => { + resolve(`Alert: ${value.toUpperCase()}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/digest.js b/bench/tasks/T05-mechanical-codemod/fixtures/digest.js new file mode 100644 index 0000000..92ddcce --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/digest.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildDigest(period) { + return new Promise((resolve) => { + formatWithCallback(period, (value) => { + resolve(`Digest: ${value.toLowerCase()}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/format.js b/bench/tasks/T05-mechanical-codemod/fixtures/format.js new file mode 100644 index 0000000..f7d1562 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/format.js @@ -0,0 +1,11 @@ +function normalize(value) { + return String(value).trim().replace(/\s+/g, " "); +} + +export function formatWithCallback(value, callback) { + queueMicrotask(() => callback(normalize(value))); +} + +export function format(value) { + return Promise.resolve(normalize(value)); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/greeting.js b/bench/tasks/T05-mechanical-codemod/fixtures/greeting.js new file mode 100644 index 0000000..041ed0d --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/greeting.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildGreeting(name) { + return new Promise((resolve) => { + formatWithCallback(name, (value) => { + resolve(`Hello, ${value}!`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/index.js b/bench/tasks/T05-mechanical-codemod/fixtures/index.js new file mode 100644 index 0000000..22cf39b --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/index.js @@ -0,0 +1,9 @@ +export { buildAlert } from "./alert.js"; +export { buildDigest } from "./digest.js"; +export { buildGreeting } from "./greeting.js"; +export { buildInvite } from "./invite.js"; +export { buildProfile } from "./profile.js"; +export { buildReceipt } from "./receipt.js"; +export { buildReminder } from "./reminder.js"; +export { buildStatus } from "./status.js"; +export { buildSummary } from "./summary.js"; diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/invite.js b/bench/tasks/T05-mechanical-codemod/fixtures/invite.js new file mode 100644 index 0000000..44806dd --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/invite.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildInvite(event) { + return new Promise((resolve) => { + formatWithCallback(event, (value) => { + resolve(`Invite: ${value}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/package.json b/bench/tasks/T05-mechanical-codemod/fixtures/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/profile.js b/bench/tasks/T05-mechanical-codemod/fixtures/profile.js new file mode 100644 index 0000000..51c8930 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/profile.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildProfile(name) { + return new Promise((resolve) => { + formatWithCallback(name, (value) => { + resolve(`Profile: ${value}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/receipt.js b/bench/tasks/T05-mechanical-codemod/fixtures/receipt.js new file mode 100644 index 0000000..ef82355 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/receipt.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildReceipt(order) { + return new Promise((resolve) => { + formatWithCallback(order, (value) => { + resolve(`Receipt: ${value}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/reminder.js b/bench/tasks/T05-mechanical-codemod/fixtures/reminder.js new file mode 100644 index 0000000..e3c2452 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/reminder.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildReminder(task) { + return new Promise((resolve) => { + formatWithCallback(task, (value) => { + resolve(`Reminder: ${value}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/status.js b/bench/tasks/T05-mechanical-codemod/fixtures/status.js new file mode 100644 index 0000000..17a2da5 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/status.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildStatus(status) { + return new Promise((resolve) => { + formatWithCallback(status, (value) => { + resolve(`Status: ${value.toUpperCase()}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/fixtures/summary.js b/bench/tasks/T05-mechanical-codemod/fixtures/summary.js new file mode 100644 index 0000000..791029c --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/fixtures/summary.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildSummary(topic) { + return new Promise((resolve) => { + formatWithCallback(topic, (value) => { + resolve(`Summary: ${value}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/format.test.mjs b/bench/tasks/T05-mechanical-codemod/format.test.mjs new file mode 100644 index 0000000..5895e75 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/format.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + buildAlert, + buildDigest, + buildGreeting, + buildInvite, + buildProfile, + buildReceipt, + buildReminder, + buildStatus, + buildSummary +} from "./index.js"; + +test("greeting, status, and reminder preserve normalized text", async () => { + assert.equal(await buildGreeting(" Ada Lovelace "), "Hello, Ada Lovelace!"); + assert.equal(await buildStatus(" ready for review "), "Status: READY FOR REVIEW"); + assert.equal(await buildReminder(" renew membership "), "Reminder: renew membership"); +}); + +test("summary, receipt, and profile preserve their public output", async () => { + assert.equal(await buildSummary(" July report "), "Summary: July report"); + assert.equal(await buildReceipt(" order 104 "), "Receipt: order 104"); + assert.equal(await buildProfile(" Morgan Lee "), "Profile: Morgan Lee"); +}); + +test("alert, invite, and digest remain asynchronous", async () => { + const alert = buildAlert(" storage nearly full "); + const invite = buildInvite(" Product review "); + const digest = buildDigest(" week 28 "); + assert.ok(alert instanceof Promise); + assert.ok(invite instanceof Promise); + assert.ok(digest instanceof Promise); + assert.deepEqual(await Promise.all([alert, invite, digest]), ["Alert: STORAGE NEARLY FULL", "Invite: Product review", "Digest: week 28"]); +}); diff --git a/bench/tasks/T05-mechanical-codemod/mutant/alert.js b/bench/tasks/T05-mechanical-codemod/mutant/alert.js new file mode 100644 index 0000000..687913f --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/alert.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildAlert(message) { + const value = await format(message); + return `Alert: ${value.toUpperCase()}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/digest.js b/bench/tasks/T05-mechanical-codemod/mutant/digest.js new file mode 100644 index 0000000..3acddba --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/digest.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildDigest(period) { + const value = await format(period); + return `Digest: ${value.toLowerCase()}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/format.js b/bench/tasks/T05-mechanical-codemod/mutant/format.js new file mode 100644 index 0000000..f7d1562 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/format.js @@ -0,0 +1,11 @@ +function normalize(value) { + return String(value).trim().replace(/\s+/g, " "); +} + +export function formatWithCallback(value, callback) { + queueMicrotask(() => callback(normalize(value))); +} + +export function format(value) { + return Promise.resolve(normalize(value)); +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/greeting.js b/bench/tasks/T05-mechanical-codemod/mutant/greeting.js new file mode 100644 index 0000000..d452951 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/greeting.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildGreeting(name) { + const value = await format(name); + return `Hello, ${value}!`; +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/index.js b/bench/tasks/T05-mechanical-codemod/mutant/index.js new file mode 100644 index 0000000..22cf39b --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/index.js @@ -0,0 +1,9 @@ +export { buildAlert } from "./alert.js"; +export { buildDigest } from "./digest.js"; +export { buildGreeting } from "./greeting.js"; +export { buildInvite } from "./invite.js"; +export { buildProfile } from "./profile.js"; +export { buildReceipt } from "./receipt.js"; +export { buildReminder } from "./reminder.js"; +export { buildStatus } from "./status.js"; +export { buildSummary } from "./summary.js"; diff --git a/bench/tasks/T05-mechanical-codemod/mutant/invite.js b/bench/tasks/T05-mechanical-codemod/mutant/invite.js new file mode 100644 index 0000000..44806dd --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/invite.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildInvite(event) { + return new Promise((resolve) => { + formatWithCallback(event, (value) => { + resolve(`Invite: ${value}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/package.json b/bench/tasks/T05-mechanical-codemod/mutant/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/profile.js b/bench/tasks/T05-mechanical-codemod/mutant/profile.js new file mode 100644 index 0000000..db10f12 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/profile.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildProfile(name) { + const value = await format(name); + return `Profile: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/receipt.js b/bench/tasks/T05-mechanical-codemod/mutant/receipt.js new file mode 100644 index 0000000..334f274 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/receipt.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildReceipt(order) { + const value = await format(order); + return `Receipt: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/reminder.js b/bench/tasks/T05-mechanical-codemod/mutant/reminder.js new file mode 100644 index 0000000..e3c2452 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/reminder.js @@ -0,0 +1,9 @@ +import { formatWithCallback } from "./format.js"; + +export function buildReminder(task) { + return new Promise((resolve) => { + formatWithCallback(task, (value) => { + resolve(`Reminder: ${value}`); + }); + }); +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/status.js b/bench/tasks/T05-mechanical-codemod/mutant/status.js new file mode 100644 index 0000000..881b3e7 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/status.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildStatus(status) { + const value = await format(status); + return `Status: ${value.toUpperCase()}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/mutant/summary.js b/bench/tasks/T05-mechanical-codemod/mutant/summary.js new file mode 100644 index 0000000..bb7a027 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/mutant/summary.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildSummary(topic) { + const value = await format(topic); + return `Summary: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/selftest.sh b/bench/tasks/T05-mechanical-codemod/selftest.sh new file mode 100755 index 0000000..988685e --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/selftest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${TASK_DIR}/verify.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +TMP_MUTANT="$(mktemp -d)" +trap 'rm -rf "$TMP_MUTANT" "$TMP_SOLUTION"' EXIT +TMP_SOLUTION="$(mktemp -d)" + +cp -R "${TASK_DIR}/mutant"/. "$TMP_MUTANT"/ +if bash "$VERIFY" "$TMP_MUTANT"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "${TASK_DIR}/solution"/. "$TMP_SOLUTION"/ +if ! bash "$VERIFY" "$TMP_SOLUTION"; then + fail "solution leg: verify.sh should accept the reference fix" +fi + +echo "PASS" diff --git a/bench/tasks/T05-mechanical-codemod/solution/alert.js b/bench/tasks/T05-mechanical-codemod/solution/alert.js new file mode 100644 index 0000000..687913f --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/alert.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildAlert(message) { + const value = await format(message); + return `Alert: ${value.toUpperCase()}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/digest.js b/bench/tasks/T05-mechanical-codemod/solution/digest.js new file mode 100644 index 0000000..3acddba --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/digest.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildDigest(period) { + const value = await format(period); + return `Digest: ${value.toLowerCase()}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/format.js b/bench/tasks/T05-mechanical-codemod/solution/format.js new file mode 100644 index 0000000..bf0c774 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/format.js @@ -0,0 +1,7 @@ +function normalize(value) { + return String(value).trim().replace(/\s+/g, " "); +} + +export function format(value) { + return Promise.resolve(normalize(value)); +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/greeting.js b/bench/tasks/T05-mechanical-codemod/solution/greeting.js new file mode 100644 index 0000000..d452951 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/greeting.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildGreeting(name) { + const value = await format(name); + return `Hello, ${value}!`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/index.js b/bench/tasks/T05-mechanical-codemod/solution/index.js new file mode 100644 index 0000000..22cf39b --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/index.js @@ -0,0 +1,9 @@ +export { buildAlert } from "./alert.js"; +export { buildDigest } from "./digest.js"; +export { buildGreeting } from "./greeting.js"; +export { buildInvite } from "./invite.js"; +export { buildProfile } from "./profile.js"; +export { buildReceipt } from "./receipt.js"; +export { buildReminder } from "./reminder.js"; +export { buildStatus } from "./status.js"; +export { buildSummary } from "./summary.js"; diff --git a/bench/tasks/T05-mechanical-codemod/solution/invite.js b/bench/tasks/T05-mechanical-codemod/solution/invite.js new file mode 100644 index 0000000..f94a4e0 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/invite.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildInvite(event) { + const value = await format(event); + return `Invite: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/package.json b/bench/tasks/T05-mechanical-codemod/solution/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/profile.js b/bench/tasks/T05-mechanical-codemod/solution/profile.js new file mode 100644 index 0000000..db10f12 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/profile.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildProfile(name) { + const value = await format(name); + return `Profile: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/receipt.js b/bench/tasks/T05-mechanical-codemod/solution/receipt.js new file mode 100644 index 0000000..334f274 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/receipt.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildReceipt(order) { + const value = await format(order); + return `Receipt: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/reminder.js b/bench/tasks/T05-mechanical-codemod/solution/reminder.js new file mode 100644 index 0000000..8602f12 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/reminder.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildReminder(task) { + const value = await format(task); + return `Reminder: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/status.js b/bench/tasks/T05-mechanical-codemod/solution/status.js new file mode 100644 index 0000000..881b3e7 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/status.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildStatus(status) { + const value = await format(status); + return `Status: ${value.toUpperCase()}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/solution/summary.js b/bench/tasks/T05-mechanical-codemod/solution/summary.js new file mode 100644 index 0000000..bb7a027 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/solution/summary.js @@ -0,0 +1,6 @@ +import { format } from "./format.js"; + +export async function buildSummary(topic) { + const value = await format(topic); + return `Summary: ${value}`; +} diff --git a/bench/tasks/T05-mechanical-codemod/verify.sh b/bench/tasks/T05-mechanical-codemod/verify.sh new file mode 100755 index 0000000..3f96440 --- /dev/null +++ b/bench/tasks/T05-mechanical-codemod/verify.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: bash verify.sh " >&2 + exit 2 +fi + +WORKDIR="$1" +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HIDDEN_TEST="${TASK_DIR}/format.test.mjs" + +if [[ ! -d "$WORKDIR" ]]; then + echo "workdir is not a directory: $WORKDIR" >&2 + exit 2 +fi + +if [[ ! -f "$HIDDEN_TEST" ]]; then + echo "hidden test missing: $HIDDEN_TEST" >&2 + exit 2 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +cp -R "$WORKDIR"/. "$TMP"/ + +LEGACY_CALLS="$(find "$TMP" -type f -name '*.js' -exec grep -n -E 'formatWithCallback[[:space:]]*\(' {} + || true)" +if [[ -n "$LEGACY_CALLS" ]]; then + echo "legacy formatWithCallback call sites remain:" >&2 + echo "$LEGACY_CALLS" >&2 + exit 1 +fi + +cp "$HIDDEN_TEST" "$TMP"/ + +cd "$TMP" +exec node --test diff --git a/bench/tasks/T06-plan-shaped-fixes/brief.md b/bench/tasks/T06-plan-shaped-fixes/brief.md new file mode 100644 index 0000000..f55b6c4 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/brief.md @@ -0,0 +1,33 @@ +# Three independent fixes + +This folder contains three separate Node ESM packages. They share no files, imports, or state, so the work packages below can be fixed in any order or in parallel. Keep each package's public API unchanged. + +## Work package 1: CSV parser + +In `csv-parser/index.js`, `parseCsvLine` must treat a comma inside double quotes as part of the current field. Reproduce the defect from `fixtures/` with: + +```sh +node --input-type=module -e 'import { parseCsvLine } from "./csv-parser/index.js"; console.log(parseCsvLine("team,\"Park, Mina\""));' +``` + +The result should contain two fields, `team` and `Park, Mina`. The current implementation incorrectly returns three fields because it splits the quoted comma. + +## Work package 2: Duration formatter + +In `duration-formatter/index.js`, `formatDuration` must use completed minutes and the remaining seconds. Reproduce the defect from `fixtures/` with: + +```sh +node --input-type=module -e 'import { formatDuration } from "./duration-formatter/index.js"; console.log(formatDuration(90));' +``` + +The result should be `1m 30s`. The current implementation rounds the minute count up and returns `2m -30s`. + +## Work package 3: Path matcher + +In `path-matcher/index.js`, `matchesPath` supports `*` as a wildcard within one path segment. Reproduce the defect from `fixtures/` with: + +```sh +node --input-type=module -e 'import { matchesPath } from "./path-matcher/index.js"; console.log(matchesPath("src/*.js", "src/nested/main.js"));' +``` + +The result should be `false`. The current implementation lets `*` cross a path separator, so it incorrectly returns `true`. diff --git a/bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/index.js b/bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/index.js new file mode 100644 index 0000000..92cb4c1 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/index.js @@ -0,0 +1,3 @@ +export function parseCsvLine(line) { + return line.split(",").map((field) => field.trim()); +} diff --git a/bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/package.json b/bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/fixtures/csv-parser/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/index.js b/bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/index.js new file mode 100644 index 0000000..5a94f9a --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/index.js @@ -0,0 +1,6 @@ +export function formatDuration(totalSeconds) { + const seconds = Math.max(0, Math.trunc(totalSeconds)); + const minutes = Math.round(seconds / 60); + const remainingSeconds = seconds - minutes * 60; + return minutes > 0 ? `${minutes}m ${remainingSeconds}s` : `${remainingSeconds}s`; +} diff --git a/bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/package.json b/bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/fixtures/duration-formatter/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/index.js b/bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/index.js new file mode 100644 index 0000000..577fa17 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/index.js @@ -0,0 +1,5 @@ +export function matchesPath(pattern, candidate) { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + const source = escaped.replaceAll("*", ".*"); + return new RegExp(`^${source}$`).test(candidate); +} diff --git a/bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/package.json b/bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/fixtures/path-matcher/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/hidden/csv-parser.test.mjs b/bench/tasks/T06-plan-shaped-fixes/hidden/csv-parser.test.mjs new file mode 100644 index 0000000..5904712 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/hidden/csv-parser.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseCsvLine } from "../csv-parser/index.js"; + +test("parses quoted commas and escaped quotes", () => { + assert.deepEqual(parseCsvLine('team,"Park, Mina","said ""hello"""'), [ + "team", + "Park, Mina", + 'said "hello"' + ]); +}); + +test("preserves ordinary trimmed fields", () => { + assert.deepEqual(parseCsvLine(" alpha, beta ,gamma "), ["alpha", "beta", "gamma"]); +}); diff --git a/bench/tasks/T06-plan-shaped-fixes/hidden/duration-formatter.test.mjs b/bench/tasks/T06-plan-shaped-fixes/hidden/duration-formatter.test.mjs new file mode 100644 index 0000000..61bb7ab --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/hidden/duration-formatter.test.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { formatDuration } from "../duration-formatter/index.js"; + +test("formats a duration with completed minutes and remaining seconds", () => { + assert.equal(formatDuration(90), "1m 30s"); + assert.equal(formatDuration(120), "2m 0s"); +}); + +test("formats durations shorter than a minute", () => { + assert.equal(formatDuration(59), "59s"); + assert.equal(formatDuration(-8), "0s"); +}); diff --git a/bench/tasks/T06-plan-shaped-fixes/hidden/path-matcher.test.mjs b/bench/tasks/T06-plan-shaped-fixes/hidden/path-matcher.test.mjs new file mode 100644 index 0000000..d82538a --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/hidden/path-matcher.test.mjs @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { matchesPath } from "../path-matcher/index.js"; + +test("matches a wildcard inside one path segment", () => { + assert.equal(matchesPath("src/*.js", "src/main.js"), true); + assert.equal(matchesPath("src/*.js", "src/main.ts"), false); +}); + +test("does not let a wildcard cross a path separator", () => { + assert.equal(matchesPath("src/*.js", "src/nested/main.js"), false); +}); diff --git a/bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/index.js b/bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/index.js new file mode 100644 index 0000000..84a6c86 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/index.js @@ -0,0 +1,25 @@ +export function parseCsvLine(line) { + const fields = []; + let field = ""; + let quoted = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (character === '"') { + if (quoted && line[index + 1] === '"') { + field += '"'; + index += 1; + } else { + quoted = !quoted; + } + } else if (character === "," && !quoted) { + fields.push(field.trim()); + field = ""; + } else { + field += character; + } + } + + fields.push(field.trim()); + return fields; +} diff --git a/bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/package.json b/bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/mutant/csv-parser/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/index.js b/bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/index.js new file mode 100644 index 0000000..0bf7d36 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/index.js @@ -0,0 +1,6 @@ +export function formatDuration(totalSeconds) { + const seconds = Math.max(0, Math.trunc(totalSeconds)); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds - minutes * 60; + return minutes > 0 ? `${minutes}m ${remainingSeconds}s` : `${remainingSeconds}s`; +} diff --git a/bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/package.json b/bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/mutant/duration-formatter/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/index.js b/bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/index.js new file mode 100644 index 0000000..577fa17 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/index.js @@ -0,0 +1,5 @@ +export function matchesPath(pattern, candidate) { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + const source = escaped.replaceAll("*", ".*"); + return new RegExp(`^${source}$`).test(candidate); +} diff --git a/bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/package.json b/bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/mutant/path-matcher/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/selftest.sh b/bench/tasks/T06-plan-shaped-fixes/selftest.sh new file mode 100755 index 0000000..b85bf1f --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/selftest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${TASK_DIR}/verify.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +TMP_MUTANT="$(mktemp -d)" +TMP_SOLUTION="$(mktemp -d)" +trap 'rm -rf "$TMP_MUTANT" "$TMP_SOLUTION"' EXIT + +cp -R "${TASK_DIR}/mutant"/. "$TMP_MUTANT"/ +if bash "$VERIFY" "$TMP_MUTANT"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "${TASK_DIR}/solution"/. "$TMP_SOLUTION"/ +if ! bash "$VERIFY" "$TMP_SOLUTION"; then + fail "solution leg: verify.sh should accept the reference fix" +fi + +echo "PASS" diff --git a/bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/index.js b/bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/index.js new file mode 100644 index 0000000..84a6c86 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/index.js @@ -0,0 +1,25 @@ +export function parseCsvLine(line) { + const fields = []; + let field = ""; + let quoted = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (character === '"') { + if (quoted && line[index + 1] === '"') { + field += '"'; + index += 1; + } else { + quoted = !quoted; + } + } else if (character === "," && !quoted) { + fields.push(field.trim()); + field = ""; + } else { + field += character; + } + } + + fields.push(field.trim()); + return fields; +} diff --git a/bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/package.json b/bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/solution/csv-parser/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/index.js b/bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/index.js new file mode 100644 index 0000000..0bf7d36 --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/index.js @@ -0,0 +1,6 @@ +export function formatDuration(totalSeconds) { + const seconds = Math.max(0, Math.trunc(totalSeconds)); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds - minutes * 60; + return minutes > 0 ? `${minutes}m ${remainingSeconds}s` : `${remainingSeconds}s`; +} diff --git a/bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/package.json b/bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/solution/duration-formatter/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/index.js b/bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/index.js new file mode 100644 index 0000000..5d08dca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/index.js @@ -0,0 +1,5 @@ +export function matchesPath(pattern, candidate) { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + const source = escaped.replaceAll("*", "[^/]*"); + return new RegExp(`^${source}$`).test(candidate); +} diff --git a/bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/package.json b/bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/solution/path-matcher/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T06-plan-shaped-fixes/verify.sh b/bench/tasks/T06-plan-shaped-fixes/verify.sh new file mode 100755 index 0000000..012d3da --- /dev/null +++ b/bench/tasks/T06-plan-shaped-fixes/verify.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + exit 2 +fi + +WORKDIR="$1" +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HIDDEN_DIR="${TASK_DIR}/hidden" + +if [[ ! -d "$WORKDIR" || ! -d "$HIDDEN_DIR" ]]; then + exit 2 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +cp -R "$WORKDIR"/. "$TMP"/ +mkdir -p "$TMP/tests" +cp -R "$HIDDEN_DIR"/. "$TMP/tests"/ + +cd "$TMP" +exec node --test tests/csv-parser.test.mjs tests/duration-formatter.test.mjs tests/path-matcher.test.mjs diff --git a/bench/tasks/T07-plan-shaped-features/brief.md b/bench/tasks/T07-plan-shaped-features/brief.md new file mode 100644 index 0000000..67c1d95 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/brief.md @@ -0,0 +1,31 @@ +# Plan shaped feature packages + +This project contains three independent feature packages. Each package owns one module and has no shared files, imports, or state with the other packages. You can implement the packages in any order or in parallel. Use only named exports. Do not add default exports. + +## Package 1: Query string codec + +Implement `parseQuery(input)` and `stringifyQuery(values)` in `query-string/index.js`. + +`parseQuery` accepts a string and returns a null prototype record. A leading `?` is optional. Ignore everything from the first `#` onward. Split the remaining text on `&`, ignoring empty segments. Split each nonempty segment at its first `=`. A segment without `=` has an empty value. Replace `+` with a space before percent decoding keys and values with `decodeURIComponent`. Keep an empty key when it occurs. A repeated key holds an array of decoded string values in encounter order. A key seen once holds its decoded string value. Nonstring input throws `TypeError`, and malformed percent escapes keep the `URIError` from `decodeURIComponent`. + +`stringifyQuery` accepts a nonnull object that is not an array. Process its own enumerable keys in `Object.keys` order. A scalar produces one `key=value` pair. An array produces one pair per item in array order. Skip `undefined` values, including `undefined` array items. Render `null` as an empty value. Render every other value with `String`. Percent encode keys and values with `encodeURIComponent`, then replace `%20` with `+`. Always include `=` in emitted pairs. Join pairs with `&`. The function must not mutate its input. Invalid input throws `TypeError`. + +## Package 2: Semantic version ranges + +Implement `parseVersion(input)`, `compareVersions(left, right)`, and `satisfiesRange(version, range)` in `semver-range/index.js`. + +`parseVersion` accepts only a string in the exact stable form `MAJOR.MINOR.PATCH`. Each component is a nonnegative safe integer with no leading zero unless it is `0`. It returns an object with numeric `major`, `minor`, and `patch` properties. Nonstring input throws `TypeError`. Invalid version text throws `RangeError`. + +`compareVersions` accepts two valid version strings and returns `-1`, `0`, or `1` according to semantic version ordering by major, minor, then patch. + +`satisfiesRange` accepts a valid version string and a range string. A range may be `*`, an exact version such as `1.2.3`, or one or more whitespace separated comparators that are all required to match. Comparators use `>`, `>=`, `<`, `<=`, or optional `=` followed immediately by an exact version. It also supports `^MAJOR.MINOR.PATCH` and `~MAJOR.MINOR.PATCH`. A caret range includes its lower bound and excludes the next major for a positive major, the next minor for `0` major and positive minor, or the next patch for `0.0` versions. A tilde range includes its lower bound and excludes the next minor version. Leading and trailing range whitespace is ignored. Empty, malformed, or unsupported ranges throw `RangeError`. A nonstring range throws `TypeError`. + +## Package 3: Text table renderer + +Implement `measureTable(headers, rows)` and `formatTable(headers, rows)` in `text-table/index.js`. + +`headers` must be a nonempty array of strings. `rows` must be an array of arrays, and every row must contain exactly one cell for each header. A cell may be a string, number, boolean, `null`, or `undefined`. Render `null` and `undefined` as an empty string and render other cell values with `String`. Reject any header or rendered cell containing `\r` or `\n` with `RangeError`. Reject invalid argument shapes or unsupported cell types with `TypeError`. + +`measureTable` returns a new array of column widths. Each width is the greatest JavaScript string length of its header and rendered cells. It must not mutate either argument. + +`formatTable` uses those widths to return an ASCII table with a top separator, a header row, a separator, every data row, and a final separator. A separator uses `+`, then `-` repeated for the column width plus two, for every column, and a final `+`. A row uses `| `, the left aligned cell padded with spaces to the column width, ` |` for every column, and no trailing whitespace after the final `|`. Separate lines with `\n` and do not append a final newline. Empty `rows` still produces the top separator, header row, middle separator, and final separator. diff --git a/bench/tasks/T07-plan-shaped-features/fixtures/package.json b/bench/tasks/T07-plan-shaped-features/fixtures/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/fixtures/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T07-plan-shaped-features/fixtures/query-string/index.js b/bench/tasks/T07-plan-shaped-features/fixtures/query-string/index.js new file mode 100644 index 0000000..8e00366 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/fixtures/query-string/index.js @@ -0,0 +1,7 @@ +export function parseQuery() { + throw new Error("not implemented"); +} + +export function stringifyQuery() { + throw new Error("not implemented"); +} diff --git a/bench/tasks/T07-plan-shaped-features/fixtures/semver-range/index.js b/bench/tasks/T07-plan-shaped-features/fixtures/semver-range/index.js new file mode 100644 index 0000000..51c3811 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/fixtures/semver-range/index.js @@ -0,0 +1,11 @@ +export function parseVersion() { + throw new Error("not implemented"); +} + +export function compareVersions() { + throw new Error("not implemented"); +} + +export function satisfiesRange() { + throw new Error("not implemented"); +} diff --git a/bench/tasks/T07-plan-shaped-features/fixtures/text-table/index.js b/bench/tasks/T07-plan-shaped-features/fixtures/text-table/index.js new file mode 100644 index 0000000..1df258c --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/fixtures/text-table/index.js @@ -0,0 +1,7 @@ +export function measureTable() { + throw new Error("not implemented"); +} + +export function formatTable() { + throw new Error("not implemented"); +} diff --git a/bench/tasks/T07-plan-shaped-features/mutant/package.json b/bench/tasks/T07-plan-shaped-features/mutant/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/mutant/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T07-plan-shaped-features/mutant/query-string/index.js b/bench/tasks/T07-plan-shaped-features/mutant/query-string/index.js new file mode 100644 index 0000000..657f02b --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/mutant/query-string/index.js @@ -0,0 +1,50 @@ +function decodePart(value) { + return decodeURIComponent(value.replace(/\+/g, " ")); +} + +function encodePart(value) { + return encodeURIComponent(String(value)).replace(/%20/g, "+"); +} + +export function parseQuery(input) { + if (typeof input !== "string") { + throw new TypeError("input must be a string"); + } + + const result = Object.create(null); + const query = (input.startsWith("?") ? input.slice(1) : input).split("#", 1)[0]; + for (const segment of query.split("&")) { + if (segment === "") { + continue; + } + const separator = segment.indexOf("="); + const key = decodePart(separator === -1 ? segment : segment.slice(0, separator)); + const value = decodePart(separator === -1 ? "" : segment.slice(separator + 1)); + if (!Object.hasOwn(result, key)) { + result[key] = value; + } else if (Array.isArray(result[key])) { + result[key].push(value); + } else { + result[key] = [result[key], value]; + } + } + return result; +} + +export function stringifyQuery(values) { + if (values === null || typeof values !== "object" || Array.isArray(values)) { + throw new TypeError("values must be an object"); + } + + const pairs = []; + for (const key of Object.keys(values)) { + const entries = Array.isArray(values[key]) ? values[key] : [values[key]]; + for (const entry of entries) { + if (entry === undefined) { + continue; + } + pairs.push(`${encodePart(key)}=${encodePart(entry === null ? "" : entry)}`); + } + } + return pairs.join("&"); +} diff --git a/bench/tasks/T07-plan-shaped-features/mutant/semver-range/index.js b/bench/tasks/T07-plan-shaped-features/mutant/semver-range/index.js new file mode 100644 index 0000000..b6e77e7 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/mutant/semver-range/index.js @@ -0,0 +1,98 @@ +function compareParsedVersions(left, right) { + for (const key of ["major", "minor", "patch"]) { + if (left[key] < right[key]) { + return -1; + } + if (left[key] > right[key]) { + return 1; + } + } + return 0; +} + +function parseRangeVersion(input) { + return parseVersion(input); +} + +function testComparator(version, operator, bound) { + const comparison = compareParsedVersions(version, bound); + if (operator === ">") { + return comparison > 0; + } + if (operator === ">=") { + return comparison >= 0; + } + if (operator === "<") { + return comparison < 0; + } + if (operator === "<=") { + return comparison <= 0; + } + return comparison === 0; +} + +function incrementComponent(version, key) { + if (version[key] === Number.MAX_SAFE_INTEGER) { + throw new RangeError("range upper bound is unsafe"); + } + const upper = { major: version.major, minor: version.minor, patch: version.patch }; + upper[key] += 1; + if (key === "major") { + upper.minor = 0; + upper.patch = 0; + } else if (key === "minor") { + upper.patch = 0; + } + return upper; +} + +export function parseVersion(input) { + if (typeof input !== "string") { + throw new TypeError("version must be a string"); + } + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(input); + if (!match) { + throw new RangeError("version must use MAJOR.MINOR.PATCH"); + } + const [major, minor, patch] = match.slice(1).map(Number); + if (![major, minor, patch].every(Number.isSafeInteger)) { + throw new RangeError("version components must be safe integers"); + } + return { major, minor, patch }; +} + +export function compareVersions(left, right) { + return compareParsedVersions(parseVersion(left), parseVersion(right)); +} + +export function satisfiesRange(version, range) { + const parsedVersion = parseVersion(version); + if (typeof range !== "string") { + throw new TypeError("range must be a string"); + } + const trimmed = range.trim(); + if (trimmed === "") { + throw new RangeError("range must not be empty"); + } + if (trimmed === "*") { + return true; + } + + return trimmed.split(/\s+/).every((part) => { + if (part.startsWith("^")) { + const lower = parseRangeVersion(part.slice(1)); + const upper = incrementComponent(lower, lower.major > 0 ? "major" : lower.minor > 0 ? "minor" : "patch"); + return testComparator(parsedVersion, ">=", lower) && testComparator(parsedVersion, "<", upper); + } + if (part.startsWith("~")) { + const lower = parseRangeVersion(part.slice(1)); + const upper = incrementComponent(lower, "minor"); + return testComparator(parsedVersion, ">=", lower) && testComparator(parsedVersion, "<", upper); + } + const match = /^(>=|<=|>|<|=)?(.+)$/.exec(part); + if (!match) { + throw new RangeError("range comparator is invalid"); + } + return testComparator(parsedVersion, match[1] ?? "=", parseRangeVersion(match[2])); + }); +} diff --git a/bench/tasks/T07-plan-shaped-features/mutant/text-table/index.js b/bench/tasks/T07-plan-shaped-features/mutant/text-table/index.js new file mode 100644 index 0000000..0dccd3f --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/mutant/text-table/index.js @@ -0,0 +1,45 @@ +function renderCell(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + throw new TypeError("cells must be strings, numbers, booleans, null, or undefined"); + } + const rendered = String(value); + if (/\r|\n/.test(rendered)) { + throw new RangeError("cells must be single line"); + } + return rendered; +} + +function validateTable(headers, rows) { + if (!Array.isArray(headers) || headers.length === 0 || headers.some((header) => typeof header !== "string")) { + throw new TypeError("headers must be a nonempty array of strings"); + } + if (headers.some((header) => /\r|\n/.test(header))) { + throw new RangeError("headers must be single line"); + } + if (!Array.isArray(rows)) { + throw new TypeError("rows must be an array"); + } + const renderedRows = rows.map((row) => { + if (!Array.isArray(row) || row.length !== headers.length) { + throw new TypeError("each row must match the header count"); + } + return row.map(renderCell); + }); + return { headers, renderedRows }; +} + +export function measureTable(headers, rows) { + const table = validateTable(headers, rows); + return table.headers.map((header) => header.length); +} + +export function formatTable(headers, rows) { + const table = validateTable(headers, rows); + const widths = measureTable(headers, rows); + const separator = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`; + const formatRow = (cells) => `| ${cells.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`; + return [separator, formatRow(table.headers), separator, ...table.renderedRows.map(formatRow), separator].join("\n"); +} diff --git a/bench/tasks/T07-plan-shaped-features/query-string.test.mjs b/bench/tasks/T07-plan-shaped-features/query-string.test.mjs new file mode 100644 index 0000000..4a5e024 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/query-string.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseQuery, stringifyQuery } from "./query-string/index.js"; + +test("parseQuery decodes a leading query marker, fragments, plus signs, and duplicate keys", () => { + const parsed = parseQuery("?name=Ada+Lovelace&tag=math&tag=logic&=blank#ignored=yes"); + assert.equal(Object.getPrototypeOf(parsed), null); + assert.deepEqual(parsed, Object.assign(Object.create(null), { + name: "Ada Lovelace", + tag: ["math", "logic"], + "": "blank" + })); +}); + +test("parseQuery keeps blank values and ignores empty segments", () => { + assert.deepEqual(parseQuery("&&flag&answer=&"), Object.assign(Object.create(null), { + flag: "", + answer: "" + })); +}); + +test("parseQuery rejects invalid input and preserves malformed escape errors", () => { + assert.throws(() => parseQuery(null), TypeError); + assert.throws(() => parseQuery("broken=%E0%A4"), URIError); +}); + +test("stringifyQuery serializes scalars, arrays, spaces, and null without mutating input", () => { + const values = { name: "Ada Lovelace", tag: ["math", null, undefined, "logic"], empty: null, skip: undefined }; + assert.equal(stringifyQuery(values), "name=Ada+Lovelace&tag=math&tag=&tag=logic&empty="); + assert.deepEqual(values, { name: "Ada Lovelace", tag: ["math", null, undefined, "logic"], empty: null, skip: undefined }); +}); + +test("stringifyQuery rejects null, arrays, and primitives", () => { + assert.throws(() => stringifyQuery(null), TypeError); + assert.throws(() => stringifyQuery(["value"]), TypeError); + assert.throws(() => stringifyQuery("value"), TypeError); +}); diff --git a/bench/tasks/T07-plan-shaped-features/selftest.sh b/bench/tasks/T07-plan-shaped-features/selftest.sh new file mode 100755 index 0000000..3ba0b27 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/selftest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${TASK_DIR}/verify.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +TMP_MUTANT="$(mktemp -d)" +TMP_SOLUTION="$(mktemp -d)" +trap 'rm -rf "$TMP_MUTANT" "$TMP_SOLUTION"' EXIT + +cp -R "${TASK_DIR}/mutant"/. "$TMP_MUTANT"/ +if bash "$VERIFY" "$TMP_MUTANT"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "${TASK_DIR}/solution"/. "$TMP_SOLUTION"/ +if ! bash "$VERIFY" "$TMP_SOLUTION"; then + fail "solution leg: verify.sh should accept the reference solution" +fi + +echo "PASS" diff --git a/bench/tasks/T07-plan-shaped-features/semver-range.test.mjs b/bench/tasks/T07-plan-shaped-features/semver-range.test.mjs new file mode 100644 index 0000000..ab0e718 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/semver-range.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { compareVersions, parseVersion, satisfiesRange } from "./semver-range/index.js"; + +test("parseVersion returns numeric stable version components", () => { + assert.deepEqual(parseVersion("12.34.56"), { major: 12, minor: 34, patch: 56 }); +}); + +test("parseVersion rejects invalid and unsafe version components", () => { + assert.throws(() => parseVersion(1), TypeError); + assert.throws(() => parseVersion("01.2.3"), RangeError); + assert.throws(() => parseVersion("1.2"), RangeError); + assert.throws(() => parseVersion("1.2.9007199254740992"), RangeError); +}); + +test("compareVersions orders every component", () => { + assert.equal(compareVersions("1.2.3", "1.2.3"), 0); + assert.equal(compareVersions("1.2.3", "1.2.4"), -1); + assert.equal(compareVersions("2.0.0", "1.99.99"), 1); +}); + +test("satisfiesRange supports exact versions and comparator intersections", () => { + assert.equal(satisfiesRange("1.2.3", "1.2.3"), true); + assert.equal(satisfiesRange("1.2.4", "=1.2.3"), false); + assert.equal(satisfiesRange("1.5.0", ">=1.2.3 <2.0.0"), true); + assert.equal(satisfiesRange("2.0.0", ">=1.2.3 <2.0.0"), false); + assert.equal(satisfiesRange("99.0.0", "*"), true); +}); + +test("satisfiesRange applies caret and tilde upper bounds", () => { + assert.equal(satisfiesRange("1.9.9", "^1.2.3"), true); + assert.equal(satisfiesRange("2.0.0", "^1.2.3"), false); + assert.equal(satisfiesRange("0.2.9", "^0.2.3"), true); + assert.equal(satisfiesRange("0.3.0", "^0.2.3"), false); + assert.equal(satisfiesRange("0.0.3", "^0.0.3"), true); + assert.equal(satisfiesRange("0.0.4", "^0.0.3"), false); + assert.equal(satisfiesRange("1.2.99", "~1.2.3"), true); + assert.equal(satisfiesRange("1.3.0", "~1.2.3"), false); +}); + +test("satisfiesRange rejects malformed ranges", () => { + assert.throws(() => satisfiesRange("1.2.3", 1), TypeError); + assert.throws(() => satisfiesRange("1.2.3", " "), RangeError); + assert.throws(() => satisfiesRange("1.2.3", "1.2.x"), RangeError); + assert.throws(() => satisfiesRange("1.2.3", "^"), RangeError); + assert.throws(() => satisfiesRange("1.2.3", "* >=1.0.0"), RangeError); +}); diff --git a/bench/tasks/T07-plan-shaped-features/solution/package.json b/bench/tasks/T07-plan-shaped-features/solution/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/solution/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T07-plan-shaped-features/solution/query-string/index.js b/bench/tasks/T07-plan-shaped-features/solution/query-string/index.js new file mode 100644 index 0000000..657f02b --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/solution/query-string/index.js @@ -0,0 +1,50 @@ +function decodePart(value) { + return decodeURIComponent(value.replace(/\+/g, " ")); +} + +function encodePart(value) { + return encodeURIComponent(String(value)).replace(/%20/g, "+"); +} + +export function parseQuery(input) { + if (typeof input !== "string") { + throw new TypeError("input must be a string"); + } + + const result = Object.create(null); + const query = (input.startsWith("?") ? input.slice(1) : input).split("#", 1)[0]; + for (const segment of query.split("&")) { + if (segment === "") { + continue; + } + const separator = segment.indexOf("="); + const key = decodePart(separator === -1 ? segment : segment.slice(0, separator)); + const value = decodePart(separator === -1 ? "" : segment.slice(separator + 1)); + if (!Object.hasOwn(result, key)) { + result[key] = value; + } else if (Array.isArray(result[key])) { + result[key].push(value); + } else { + result[key] = [result[key], value]; + } + } + return result; +} + +export function stringifyQuery(values) { + if (values === null || typeof values !== "object" || Array.isArray(values)) { + throw new TypeError("values must be an object"); + } + + const pairs = []; + for (const key of Object.keys(values)) { + const entries = Array.isArray(values[key]) ? values[key] : [values[key]]; + for (const entry of entries) { + if (entry === undefined) { + continue; + } + pairs.push(`${encodePart(key)}=${encodePart(entry === null ? "" : entry)}`); + } + } + return pairs.join("&"); +} diff --git a/bench/tasks/T07-plan-shaped-features/solution/semver-range/index.js b/bench/tasks/T07-plan-shaped-features/solution/semver-range/index.js new file mode 100644 index 0000000..b6e77e7 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/solution/semver-range/index.js @@ -0,0 +1,98 @@ +function compareParsedVersions(left, right) { + for (const key of ["major", "minor", "patch"]) { + if (left[key] < right[key]) { + return -1; + } + if (left[key] > right[key]) { + return 1; + } + } + return 0; +} + +function parseRangeVersion(input) { + return parseVersion(input); +} + +function testComparator(version, operator, bound) { + const comparison = compareParsedVersions(version, bound); + if (operator === ">") { + return comparison > 0; + } + if (operator === ">=") { + return comparison >= 0; + } + if (operator === "<") { + return comparison < 0; + } + if (operator === "<=") { + return comparison <= 0; + } + return comparison === 0; +} + +function incrementComponent(version, key) { + if (version[key] === Number.MAX_SAFE_INTEGER) { + throw new RangeError("range upper bound is unsafe"); + } + const upper = { major: version.major, minor: version.minor, patch: version.patch }; + upper[key] += 1; + if (key === "major") { + upper.minor = 0; + upper.patch = 0; + } else if (key === "minor") { + upper.patch = 0; + } + return upper; +} + +export function parseVersion(input) { + if (typeof input !== "string") { + throw new TypeError("version must be a string"); + } + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(input); + if (!match) { + throw new RangeError("version must use MAJOR.MINOR.PATCH"); + } + const [major, minor, patch] = match.slice(1).map(Number); + if (![major, minor, patch].every(Number.isSafeInteger)) { + throw new RangeError("version components must be safe integers"); + } + return { major, minor, patch }; +} + +export function compareVersions(left, right) { + return compareParsedVersions(parseVersion(left), parseVersion(right)); +} + +export function satisfiesRange(version, range) { + const parsedVersion = parseVersion(version); + if (typeof range !== "string") { + throw new TypeError("range must be a string"); + } + const trimmed = range.trim(); + if (trimmed === "") { + throw new RangeError("range must not be empty"); + } + if (trimmed === "*") { + return true; + } + + return trimmed.split(/\s+/).every((part) => { + if (part.startsWith("^")) { + const lower = parseRangeVersion(part.slice(1)); + const upper = incrementComponent(lower, lower.major > 0 ? "major" : lower.minor > 0 ? "minor" : "patch"); + return testComparator(parsedVersion, ">=", lower) && testComparator(parsedVersion, "<", upper); + } + if (part.startsWith("~")) { + const lower = parseRangeVersion(part.slice(1)); + const upper = incrementComponent(lower, "minor"); + return testComparator(parsedVersion, ">=", lower) && testComparator(parsedVersion, "<", upper); + } + const match = /^(>=|<=|>|<|=)?(.+)$/.exec(part); + if (!match) { + throw new RangeError("range comparator is invalid"); + } + return testComparator(parsedVersion, match[1] ?? "=", parseRangeVersion(match[2])); + }); +} diff --git a/bench/tasks/T07-plan-shaped-features/solution/text-table/index.js b/bench/tasks/T07-plan-shaped-features/solution/text-table/index.js new file mode 100644 index 0000000..6b2f1ff --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/solution/text-table/index.js @@ -0,0 +1,45 @@ +function renderCell(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + throw new TypeError("cells must be strings, numbers, booleans, null, or undefined"); + } + const rendered = String(value); + if (/\r|\n/.test(rendered)) { + throw new RangeError("cells must be single line"); + } + return rendered; +} + +function validateTable(headers, rows) { + if (!Array.isArray(headers) || headers.length === 0 || headers.some((header) => typeof header !== "string")) { + throw new TypeError("headers must be a nonempty array of strings"); + } + if (headers.some((header) => /\r|\n/.test(header))) { + throw new RangeError("headers must be single line"); + } + if (!Array.isArray(rows)) { + throw new TypeError("rows must be an array"); + } + const renderedRows = rows.map((row) => { + if (!Array.isArray(row) || row.length !== headers.length) { + throw new TypeError("each row must match the header count"); + } + return row.map(renderCell); + }); + return { headers, renderedRows }; +} + +export function measureTable(headers, rows) { + const table = validateTable(headers, rows); + return table.headers.map((header, index) => Math.max(header.length, ...table.renderedRows.map((row) => row[index].length))); +} + +export function formatTable(headers, rows) { + const table = validateTable(headers, rows); + const widths = measureTable(headers, rows); + const separator = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`; + const formatRow = (cells) => `| ${cells.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`; + return [separator, formatRow(table.headers), separator, ...table.renderedRows.map(formatRow), separator].join("\n"); +} diff --git a/bench/tasks/T07-plan-shaped-features/text-table.test.mjs b/bench/tasks/T07-plan-shaped-features/text-table.test.mjs new file mode 100644 index 0000000..6197c33 --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/text-table.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { formatTable, measureTable } from "./text-table/index.js"; + +test("measureTable uses the longest header or rendered cell without mutation", () => { + const headers = ["Name", "Score"]; + const rows = [["Grace Hopper", 1000], ["Bo", null]]; + assert.deepEqual(measureTable(headers, rows), [12, 5]); + assert.deepEqual(headers, ["Name", "Score"]); + assert.deepEqual(rows, [["Grace Hopper", 1000], ["Bo", null]]); +}); + +test("formatTable renders aligned headers and cells with every required separator", () => { + assert.equal( + formatTable(["Name", "Score"], [["Grace Hopper", 1000], ["Bo", null]]), + "+--------------+-------+\n| Name | Score |\n+--------------+-------+\n| Grace Hopper | 1000 |\n| Bo | |\n+--------------+-------+" + ); +}); + +test("formatTable renders an empty body with a final separator", () => { + assert.equal(formatTable(["Only"], []), "+------+\n| Only |\n+------+\n+------+"); +}); + +test("table functions reject invalid shapes, cells, and line breaks", () => { + assert.throws(() => measureTable([], []), TypeError); + assert.throws(() => measureTable(["A"], [["one", "two"]]), TypeError); + assert.throws(() => measureTable(["A"], [[{}]]), TypeError); + assert.throws(() => formatTable(["A\nB"], []), RangeError); + assert.throws(() => formatTable(["A"], [["B\r"]]), RangeError); +}); diff --git a/bench/tasks/T07-plan-shaped-features/verify.sh b/bench/tasks/T07-plan-shaped-features/verify.sh new file mode 100755 index 0000000..e461f3b --- /dev/null +++ b/bench/tasks/T07-plan-shaped-features/verify.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: bash verify.sh " >&2 + exit 2 +fi + +WORKDIR="$1" +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ ! -d "$WORKDIR" ]]; then + echo "workdir is not a directory: $WORKDIR" >&2 + exit 2 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +cp -R "$WORKDIR"/. "$TMP"/ +cp "${TASK_DIR}/query-string.test.mjs" "${TASK_DIR}/semver-range.test.mjs" "${TASK_DIR}/text-table.test.mjs" "$TMP"/ + +cd "$TMP" +exec node --test query-string.test.mjs semver-range.test.mjs text-table.test.mjs diff --git a/bench/tasks/T08-plan-shaped-migrations/brief.md b/bench/tasks/T08-plan-shaped-migrations/brief.md new file mode 100644 index 0000000..5bf3337 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/brief.md @@ -0,0 +1,17 @@ +# Plan shaped configuration migrations + +This project is a small Node ESM package with three independent settings modules under fixtures. Each module pairs one config file with one loader that reads it, and each module's config file still uses an old format that needs migrating to the shape described below. The three modules share no files, imports, or state, so the three packages below can be migrated in any order or in parallel. Every loader's exported function name and its returned field names stay exactly as they are today; only how each loader reads its own config file changes. + +## Package 1: Queue settings + +`queue-settings/config.json` is a flat JSON object whose keys are dotted strings, such as `"queue.concurrency"`. Migrate it to a nested JSON object with one top level `queue` key holding `concurrency`, `retryDelayMs`, and `maxAttempts` as plain fields, keeping their current values. Update `queue-settings/load-queue-settings.js` so `loadQueueSettings()` reads the new nested `queue` object instead of the old dotted keys. `loadQueueSettings()` must keep returning an object with the same `concurrency`, `retryDelayMs`, and `maxAttempts` fields it returns today. + +## Package 2: Mailer settings + +`mailer-settings/mailer.env` is a plain text file of `MAILER_HOST`, `MAILER_PORT`, and `MAILER_FROM` lines in `key=value` form. Replace it with a JSON file at `mailer-settings/mailer.json` holding `host`, `port`, and `from` fields, with `port` written as a JSON number rather than text, and remove `mailer.env` once `mailer.json` takes over. Update `mailer-settings/load-mailer-settings.js` so `loadMailerSettings()` reads `mailer.json` instead of parsing the old text file. `loadMailerSettings()` must keep returning an object with the same `host`, `port`, and `from` fields it returns today, with `port` still a number. + +## Package 3: Notifier settings + +`notifier-settings/config.json` is a nested JSON object whose `notifier` key holds `channel`, `retryCount`, and `silentMode` fields. `retryCount` and `silentMode` are deprecated field names. Rename `retryCount` to `maxRetries` and `silentMode` to `muted`, keeping their current values and keeping `channel` unchanged. Update `notifier-settings/load-notifier-settings.js` so `loadNotifierSettings()` reads the renamed fields. `loadNotifierSettings()` must keep returning an object with the same `channel`, `maxRetries`, and `muted` fields it returns today. + +Everything here uses Node's built in modules only. Do not add dependencies, and do not change any exported function's name or its public behavior beyond what each package above asks for. diff --git a/bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/load-mailer-settings.js b/bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/load-mailer-settings.js new file mode 100644 index 0000000..0a3293d --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/load-mailer-settings.js @@ -0,0 +1,21 @@ +import fs from "node:fs"; + +export function loadMailerSettings() { + const raw = fs.readFileSync(new URL("./mailer.env", import.meta.url), "utf8"); + const values = {}; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const separatorIndex = trimmed.indexOf("="); + const key = trimmed.slice(0, separatorIndex); + const value = trimmed.slice(separatorIndex + 1); + values[key] = value; + } + return { + host: values.MAILER_HOST, + port: Number(values.MAILER_PORT), + from: values.MAILER_FROM + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/mailer.env b/bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/mailer.env new file mode 100644 index 0000000..7a508ec --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/fixtures/mailer-settings/mailer.env @@ -0,0 +1,3 @@ +MAILER_HOST=smtp.example.com +MAILER_PORT=587 +MAILER_FROM=noreply@example.com diff --git a/bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/config.json b/bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/config.json new file mode 100644 index 0000000..3b62d67 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/config.json @@ -0,0 +1,7 @@ +{ + "notifier": { + "channel": "email", + "retryCount": 5, + "silentMode": false + } +} diff --git a/bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/load-notifier-settings.js b/bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/load-notifier-settings.js new file mode 100644 index 0000000..e911fdc --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/fixtures/notifier-settings/load-notifier-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadNotifierSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./config.json", import.meta.url), "utf8")); + return { + channel: raw.notifier.channel, + maxRetries: raw.notifier.retryCount, + muted: raw.notifier.silentMode + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/fixtures/package.json b/bench/tasks/T08-plan-shaped-migrations/fixtures/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/fixtures/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/config.json b/bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/config.json new file mode 100644 index 0000000..ef5ffa3 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/config.json @@ -0,0 +1,5 @@ +{ + "queue.concurrency": 4, + "queue.retryDelayMs": 250, + "queue.maxAttempts": 3 +} diff --git a/bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/load-queue-settings.js b/bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/load-queue-settings.js new file mode 100644 index 0000000..518e1c5 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/fixtures/queue-settings/load-queue-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadQueueSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./config.json", import.meta.url), "utf8")); + return { + concurrency: raw["queue.concurrency"], + retryDelayMs: raw["queue.retryDelayMs"], + maxAttempts: raw["queue.maxAttempts"] + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/mailer-settings.test.mjs b/bench/tasks/T08-plan-shaped-migrations/mailer-settings.test.mjs new file mode 100644 index 0000000..c2b0564 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mailer-settings.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { test } from "node:test"; +import { loadMailerSettings } from "./mailer-settings/load-mailer-settings.js"; + +test("mailer settings drops the env style file for a json file with a numeric port", () => { + assert.equal( + fs.existsSync(new URL("./mailer-settings/mailer.env", import.meta.url)), + false, + "mailer.env should be removed once mailer.json takes over" + ); + const raw = JSON.parse(fs.readFileSync(new URL("./mailer-settings/mailer.json", import.meta.url), "utf8")); + assert.equal(typeof raw.port, "number"); + assert.deepEqual(raw, { host: "smtp.example.com", port: 587, from: "noreply@example.com" }); +}); + +test("loadMailerSettings reads the migrated json file", () => { + assert.deepEqual(loadMailerSettings(), { host: "smtp.example.com", port: 587, from: "noreply@example.com" }); +}); diff --git a/bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/load-mailer-settings.js b/bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/load-mailer-settings.js new file mode 100644 index 0000000..c7c157a --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/load-mailer-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadMailerSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./mailer.json", import.meta.url), "utf8")); + return { + host: raw.host, + port: raw.port, + from: raw.from + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/mailer.json b/bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/mailer.json new file mode 100644 index 0000000..be3eb47 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mutant/mailer-settings/mailer.json @@ -0,0 +1,5 @@ +{ + "host": "smtp.example.com", + "port": 587, + "from": "noreply@example.com" +} diff --git a/bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/config.json b/bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/config.json new file mode 100644 index 0000000..8f7e617 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/config.json @@ -0,0 +1,7 @@ +{ + "notifier": { + "channel": "email", + "maxRetries": 5, + "muted": false + } +} diff --git a/bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/load-notifier-settings.js b/bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/load-notifier-settings.js new file mode 100644 index 0000000..e911fdc --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mutant/notifier-settings/load-notifier-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadNotifierSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./config.json", import.meta.url), "utf8")); + return { + channel: raw.notifier.channel, + maxRetries: raw.notifier.retryCount, + muted: raw.notifier.silentMode + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/mutant/package.json b/bench/tasks/T08-plan-shaped-migrations/mutant/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mutant/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/config.json b/bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/config.json new file mode 100644 index 0000000..6e898f0 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/config.json @@ -0,0 +1,7 @@ +{ + "queue": { + "concurrency": 4, + "retryDelayMs": 250, + "maxAttempts": 3 + } +} diff --git a/bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/load-queue-settings.js b/bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/load-queue-settings.js new file mode 100644 index 0000000..fde80b8 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/mutant/queue-settings/load-queue-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadQueueSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./config.json", import.meta.url), "utf8")); + return { + concurrency: raw.queue.concurrency, + retryDelayMs: raw.queue.retryDelayMs, + maxAttempts: raw.queue.maxAttempts + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/notifier-settings.test.mjs b/bench/tasks/T08-plan-shaped-migrations/notifier-settings.test.mjs new file mode 100644 index 0000000..099dc46 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/notifier-settings.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { test } from "node:test"; +import { loadNotifierSettings } from "./notifier-settings/load-notifier-settings.js"; + +test("notifier settings config drops the deprecated field names", () => { + const raw = JSON.parse(fs.readFileSync(new URL("./notifier-settings/config.json", import.meta.url), "utf8")); + assert.equal("retryCount" in raw.notifier, false, "deprecated retryCount key survived migration"); + assert.equal("silentMode" in raw.notifier, false, "deprecated silentMode key survived migration"); + assert.deepEqual(raw.notifier, { channel: "email", maxRetries: 5, muted: false }); +}); + +test("loadNotifierSettings reads the renamed fields", () => { + assert.deepEqual(loadNotifierSettings(), { channel: "email", maxRetries: 5, muted: false }); +}); diff --git a/bench/tasks/T08-plan-shaped-migrations/queue-settings.test.mjs b/bench/tasks/T08-plan-shaped-migrations/queue-settings.test.mjs new file mode 100644 index 0000000..c9061ae --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/queue-settings.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { test } from "node:test"; +import { loadQueueSettings } from "./queue-settings/load-queue-settings.js"; + +test("queue settings config drops the dotted flat keys for a nested queue object", () => { + const raw = JSON.parse(fs.readFileSync(new URL("./queue-settings/config.json", import.meta.url), "utf8")); + for (const key of Object.keys(raw)) { + assert.equal(key.includes("."), false, `legacy dotted key survived migration: ${key}`); + } + assert.deepEqual(raw.queue, { concurrency: 4, retryDelayMs: 250, maxAttempts: 3 }); +}); + +test("loadQueueSettings reads the migrated nested shape", () => { + assert.deepEqual(loadQueueSettings(), { concurrency: 4, retryDelayMs: 250, maxAttempts: 3 }); +}); diff --git a/bench/tasks/T08-plan-shaped-migrations/selftest.sh b/bench/tasks/T08-plan-shaped-migrations/selftest.sh new file mode 100755 index 0000000..3ba0b27 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/selftest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${TASK_DIR}/verify.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +TMP_MUTANT="$(mktemp -d)" +TMP_SOLUTION="$(mktemp -d)" +trap 'rm -rf "$TMP_MUTANT" "$TMP_SOLUTION"' EXIT + +cp -R "${TASK_DIR}/mutant"/. "$TMP_MUTANT"/ +if bash "$VERIFY" "$TMP_MUTANT"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "${TASK_DIR}/solution"/. "$TMP_SOLUTION"/ +if ! bash "$VERIFY" "$TMP_SOLUTION"; then + fail "solution leg: verify.sh should accept the reference solution" +fi + +echo "PASS" diff --git a/bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/load-mailer-settings.js b/bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/load-mailer-settings.js new file mode 100644 index 0000000..c7c157a --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/load-mailer-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadMailerSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./mailer.json", import.meta.url), "utf8")); + return { + host: raw.host, + port: raw.port, + from: raw.from + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/mailer.json b/bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/mailer.json new file mode 100644 index 0000000..be3eb47 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/solution/mailer-settings/mailer.json @@ -0,0 +1,5 @@ +{ + "host": "smtp.example.com", + "port": 587, + "from": "noreply@example.com" +} diff --git a/bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/config.json b/bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/config.json new file mode 100644 index 0000000..8f7e617 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/config.json @@ -0,0 +1,7 @@ +{ + "notifier": { + "channel": "email", + "maxRetries": 5, + "muted": false + } +} diff --git a/bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/load-notifier-settings.js b/bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/load-notifier-settings.js new file mode 100644 index 0000000..c14f541 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/solution/notifier-settings/load-notifier-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadNotifierSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./config.json", import.meta.url), "utf8")); + return { + channel: raw.notifier.channel, + maxRetries: raw.notifier.maxRetries, + muted: raw.notifier.muted + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/solution/package.json b/bench/tasks/T08-plan-shaped-migrations/solution/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/solution/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/config.json b/bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/config.json new file mode 100644 index 0000000..6e898f0 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/config.json @@ -0,0 +1,7 @@ +{ + "queue": { + "concurrency": 4, + "retryDelayMs": 250, + "maxAttempts": 3 + } +} diff --git a/bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/load-queue-settings.js b/bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/load-queue-settings.js new file mode 100644 index 0000000..fde80b8 --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/solution/queue-settings/load-queue-settings.js @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +export function loadQueueSettings() { + const raw = JSON.parse(fs.readFileSync(new URL("./config.json", import.meta.url), "utf8")); + return { + concurrency: raw.queue.concurrency, + retryDelayMs: raw.queue.retryDelayMs, + maxAttempts: raw.queue.maxAttempts + }; +} diff --git a/bench/tasks/T08-plan-shaped-migrations/verify.sh b/bench/tasks/T08-plan-shaped-migrations/verify.sh new file mode 100755 index 0000000..a63268e --- /dev/null +++ b/bench/tasks/T08-plan-shaped-migrations/verify.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: bash verify.sh " >&2 + exit 2 +fi + +WORKDIR="$1" +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ ! -d "$WORKDIR" ]]; then + echo "workdir is not a directory: $WORKDIR" >&2 + exit 2 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +cp -R "$WORKDIR"/. "$TMP"/ +cp "${TASK_DIR}/queue-settings.test.mjs" "${TASK_DIR}/mailer-settings.test.mjs" "${TASK_DIR}/notifier-settings.test.mjs" "$TMP"/ + +cd "$TMP" +exec node --test queue-settings.test.mjs mailer-settings.test.mjs notifier-settings.test.mjs diff --git a/bench/tasks/manifest.json b/bench/tasks/manifest.json index 61a63af..cec1abd 100644 --- a/bench/tasks/manifest.json +++ b/bench/tasks/manifest.json @@ -1,5 +1,5 @@ { - "manifestHash": "95091a2baf7fba53922431f03e60e7314b166080320494dd8486d2186b7e86f4", + "manifestHash": "21185f22eb2940fa69974d417450b56e48004b39dd31bb931a7ef179e0ef4269", "tasks": [ { "id": "T01-seeded-bug", @@ -70,6 +70,668 @@ "sha256": "0083db06745d65885b2847eb9b21af308edb41cae936ab8418dd421565ea8bb7" } ] + }, + { + "id": "T02-negative-control", + "taskSha256": "aa5312d7edd53c9daf14ecec9efda9f2760d96944df290bd1ab469e6fc5a63f0", + "files": [ + { + "path": "brief.md", + "sha256": "1903d04c597358eb428b21d1a6f2af6441e47ad72a705839ac4c52f8b4d55262" + }, + { + "path": "fixtures/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "fixtures/range.js", + "sha256": "fb72b54da770dc61a2b9e454b92a515bfe21feb7cd19cd14995bedf4bdff7d0f" + }, + { + "path": "mutant/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "mutant/range.js", + "sha256": "fb72b54da770dc61a2b9e454b92a515bfe21feb7cd19cd14995bedf4bdff7d0f" + }, + { + "path": "range.test.mjs", + "sha256": "3316cad36d24c6b7de68607ac9bfe07801939d4a2e30a2a3f63e33f62f20f2a1" + }, + { + "path": "selftest.sh", + "sha256": "5fbf94d686aa4418cb1ced471e34e3efa212bd6b7a0a20acb61ad0ef01ddbdc9" + }, + { + "path": "solution/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "solution/range.js", + "sha256": "c0fb2f0bcb4a5a571613dbc20fca57d05dc0ce8b30edd352a7d1e8a48c1dde65" + }, + { + "path": "verify.sh", + "sha256": "ad158945c80c0cd48005d37bbab95bb37b8b3a6b5a84188ccff429c81c9c992c" + } + ] + }, + { + "id": "T03-spec-implementation", + "taskSha256": "3f159e5dab20b26399c14a659864b0f624be3df59aa0b7e4caa99bb7fd74ed1d", + "files": [ + { + "path": "brief.md", + "sha256": "7cb2e350fed873fcf29a744b83007923f614071d8aa0c64dc56257d3ac2aebdd" + }, + { + "path": "event-bus.test.mjs", + "sha256": "c9c4a3157f27e1b87d5f6ace912a76001bd2130e5d1bcca2656fbec004b79d20" + }, + { + "path": "fixtures/event-bus.js", + "sha256": "2e1a342b222e4d8183d4d180ded989d0d157cacd851f08b478a975ba4b556193" + }, + { + "path": "fixtures/index.js", + "sha256": "f1d88c8e2b27027e6838a3ab0b8088d65c2ec555da0288d56cf39868545bee8b" + }, + { + "path": "fixtures/match-pattern.js", + "sha256": "aa2c13668e5936dac242ee798207dcf55bdc359402e70af24b9833344febade5" + }, + { + "path": "fixtures/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "fixtures/subscriptions.js", + "sha256": "4f606ae3e0f4dd4717d9bf480e22961aee5a89f86652d78f17c245004a41ac53" + }, + { + "path": "mutant/event-bus.js", + "sha256": "50f8a72a816a2e06caa678abc9db3dfd056fd7b2b7171d9e638e6dc71c1e9124" + }, + { + "path": "mutant/index.js", + "sha256": "f1d88c8e2b27027e6838a3ab0b8088d65c2ec555da0288d56cf39868545bee8b" + }, + { + "path": "mutant/match-pattern.js", + "sha256": "a161185182b6260a5d14d5185b56c56da7bc73b261d3e1095fb19d0a3ba2f944" + }, + { + "path": "mutant/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "mutant/subscriptions.js", + "sha256": "11660d58bb0f8e70af9aae5911d66d2311eccc1897d787a84fc164e13de46c0e" + }, + { + "path": "selftest.sh", + "sha256": "779164359601614a062959ac39556d3b1617b06bb0ac872f2ae89105654643a6" + }, + { + "path": "solution/event-bus.js", + "sha256": "50f8a72a816a2e06caa678abc9db3dfd056fd7b2b7171d9e638e6dc71c1e9124" + }, + { + "path": "solution/index.js", + "sha256": "f1d88c8e2b27027e6838a3ab0b8088d65c2ec555da0288d56cf39868545bee8b" + }, + { + "path": "solution/match-pattern.js", + "sha256": "068379db681e9ff5bc1fea9398ad626402bcd9966eba0dfbcb937e71a1843aa1" + }, + { + "path": "solution/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "solution/subscriptions.js", + "sha256": "11660d58bb0f8e70af9aae5911d66d2311eccc1897d787a84fc164e13de46c0e" + }, + { + "path": "verify.sh", + "sha256": "478d97d26684802e24345fc7aa61932a6c0ab2effe98dc638fbfdfc7c125b4e3" + } + ] + }, + { + "id": "T04-checkable-research", + "taskSha256": "7cf1e6ac03e4248b4f733b77639f2b4a373a10b195dedad871271b422c242dd8", + "files": [ + { + "path": "brief.md", + "sha256": "21039740ca5ce88f0c6a77def9bd3d05dad102776a36f47f69e325e2a759fdb5" + }, + { + "path": "check-answer.mjs", + "sha256": "a745473ce1bcc1c393929622641a68581b1a6f6a4f7f1ac0680bbb24a29c78c8" + }, + { + "path": "fixtures/corpus/changelog-v1-v2.md", + "sha256": "14cf68b58bd6d2329e3c839eb3e3a139d3819a95efe367521f0172d61398fa50" + }, + { + "path": "fixtures/corpus/changelog-v3.md", + "sha256": "b40e00ca65189b128cf35a1eaa2712c8a006519d15e78f198b6839668bf6b3dc" + }, + { + "path": "fixtures/corpus/changelog-v4.md", + "sha256": "2dc81d1fc41cbc9281827d2b3140324d6f16b7f8c3ee3aa2966eb2f8706564e3" + }, + { + "path": "fixtures/corpus/config-reference.md", + "sha256": "d064d68b791b94a5f84adb7027a890c70956179a568c98edb17b48b346469c08" + }, + { + "path": "fixtures/corpus/deprecation-notice.md", + "sha256": "d227965b821140034d7721990def5fa3e23effd91806b026c2d7a246a947f84f" + }, + { + "path": "fixtures/corpus/faq.md", + "sha256": "02467af2fe08f2c538386f113c4ac5dc65bfc165508d9e7735a2d446b5f235aa" + }, + { + "path": "fixtures/corpus/known-issues.md", + "sha256": "2c69a50fab75b71c5f15fe8a3d94d04ef87b367552e1abe4b55de0eca7da2942" + }, + { + "path": "fixtures/corpus/migration-guide-v3-to-v4.md", + "sha256": "d057e3ea0ac950c8f59dd35891d7dc212636fd0f3f61e5598c3f8d1d4f095a59" + }, + { + "path": "fixtures/corpus/release-notes-v2.0.0.md", + "sha256": "b827a2cac65aafd6464b4ac4e29839c54c63b9b831ba20c61206871f45758b60" + }, + { + "path": "fixtures/corpus/release-notes-v4.1.0.md", + "sha256": "4bdf312e5d1b81fa9e4920466ead911d7eec3767f556ce9175bb52c5de7370fc" + }, + { + "path": "fixtures/corpus/support-ticket-digest.md", + "sha256": "47a221009e315ae54656968d6c70024e7ca56e70feeb4701200ab36c64cb8e0c" + }, + { + "path": "mutant/answer.json", + "sha256": "82af0ece5eb0308b20f18ce43204f84b88707966335bf640578f6781dae8ab6f" + }, + { + "path": "selftest.sh", + "sha256": "5fbf94d686aa4418cb1ced471e34e3efa212bd6b7a0a20acb61ad0ef01ddbdc9" + }, + { + "path": "solution/answer.json", + "sha256": "0868d02c9cbcedc36876ad885955127507600fe5b203959bac6709feb9bd24e9" + }, + { + "path": "verify.sh", + "sha256": "1b0d937096d004837b3bd9f1749c545bad794c726fca190a2481bccf59f66264" + } + ] + }, + { + "id": "T05-mechanical-codemod", + "taskSha256": "d4f05099ca6e102ab667b6e70ac6a5e7f41eb8e9e6f6e1cee1c975cb208439f8", + "files": [ + { + "path": "brief.md", + "sha256": "ea2699d0ed2c9e887ff002dc87ae538c07c6e3e2118a0a20fb4dd0cfb672fbf3" + }, + { + "path": "fixtures/alert.js", + "sha256": "1d7654e140361d1024b49e25c65a7a73492a758e7cf6b629253b70f970dc8ee0" + }, + { + "path": "fixtures/digest.js", + "sha256": "2ba9746039dfc511356158a13ad96d45dcded362ae68598ef856c14fbb6a938d" + }, + { + "path": "fixtures/format.js", + "sha256": "d4742b8eac142ff2753e799d9b17598308d2145ea4a8266e0ff17cea4f762ec0" + }, + { + "path": "fixtures/greeting.js", + "sha256": "775707af3c3093dbfbe713a7aec2664b23ae3122aba2bf40c636712ea7da4785" + }, + { + "path": "fixtures/index.js", + "sha256": "482643787a0c756e9ddd052933114871c2837b209fd9d97dc16a04b30687d66e" + }, + { + "path": "fixtures/invite.js", + "sha256": "b3d6702d953831b50e09234710dcc08f632dc56ac20de5c3a08e4976b32743df" + }, + { + "path": "fixtures/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "fixtures/profile.js", + "sha256": "cce68f8c0fb379299058078056212076daf71048389ec27d1050b89db67f813e" + }, + { + "path": "fixtures/receipt.js", + "sha256": "40b9d7f2028ce77bfdf1eb948e1611db0bd867798cb68c89dac6682afcb2fd50" + }, + { + "path": "fixtures/reminder.js", + "sha256": "8ade53d60a13f54929d7755402f99de350752b1e30522f9999da8c0bdf90b6f8" + }, + { + "path": "fixtures/status.js", + "sha256": "c507e18c33a45d25a8959683e9691f67b8f9d5922cfeeba2805cbdcdde7757b2" + }, + { + "path": "fixtures/summary.js", + "sha256": "c1c7c02d6f773638a2863b6a6159214c488f9e787fc352fe37430fb828c27ad5" + }, + { + "path": "format.test.mjs", + "sha256": "2c00eab4b7290e98dc7667efab7fa534c98c067a8b8f32d4b96d3753c5ddf8db" + }, + { + "path": "mutant/alert.js", + "sha256": "0f92a7300a29b895a70e50f27a9996fe28eca0c8712e8f15b41dcd8df24c5b0f" + }, + { + "path": "mutant/digest.js", + "sha256": "c24a19265d367424b4607a7310617f60c3be2260e68768d81711fa0e0700b45d" + }, + { + "path": "mutant/format.js", + "sha256": "d4742b8eac142ff2753e799d9b17598308d2145ea4a8266e0ff17cea4f762ec0" + }, + { + "path": "mutant/greeting.js", + "sha256": "9d7b0642e6ce9eca2b81c98273c23e5fdeb33a9f74bcb2e47aef98aa0ea39094" + }, + { + "path": "mutant/index.js", + "sha256": "482643787a0c756e9ddd052933114871c2837b209fd9d97dc16a04b30687d66e" + }, + { + "path": "mutant/invite.js", + "sha256": "b3d6702d953831b50e09234710dcc08f632dc56ac20de5c3a08e4976b32743df" + }, + { + "path": "mutant/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "mutant/profile.js", + "sha256": "715727946299f84b7174c95a84a8fe71e80248edc3a8103af44f1bd5654cd07f" + }, + { + "path": "mutant/receipt.js", + "sha256": "15c8135e366b075c258cb6dd3c58faaa1677e1ba3e16b1c1f59b49c11de6186d" + }, + { + "path": "mutant/reminder.js", + "sha256": "8ade53d60a13f54929d7755402f99de350752b1e30522f9999da8c0bdf90b6f8" + }, + { + "path": "mutant/status.js", + "sha256": "57d16c3db2885290ad62ca136655eb428da12067cab51bf5d690e15f4d495350" + }, + { + "path": "mutant/summary.js", + "sha256": "385f76644fd912f1eeb05ea52e10549bbdd178028754a67e8d099866900b8f51" + }, + { + "path": "selftest.sh", + "sha256": "5fbf94d686aa4418cb1ced471e34e3efa212bd6b7a0a20acb61ad0ef01ddbdc9" + }, + { + "path": "solution/alert.js", + "sha256": "0f92a7300a29b895a70e50f27a9996fe28eca0c8712e8f15b41dcd8df24c5b0f" + }, + { + "path": "solution/digest.js", + "sha256": "c24a19265d367424b4607a7310617f60c3be2260e68768d81711fa0e0700b45d" + }, + { + "path": "solution/format.js", + "sha256": "d16f05a48154735c7a0dc2fd99ddf6a1556abe05991708ae4bff6bfeff0f29c4" + }, + { + "path": "solution/greeting.js", + "sha256": "9d7b0642e6ce9eca2b81c98273c23e5fdeb33a9f74bcb2e47aef98aa0ea39094" + }, + { + "path": "solution/index.js", + "sha256": "482643787a0c756e9ddd052933114871c2837b209fd9d97dc16a04b30687d66e" + }, + { + "path": "solution/invite.js", + "sha256": "611459cc991121c67898b2bda09ad5faea9c1a1d5d50137ddb390e08bd6a5e00" + }, + { + "path": "solution/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "solution/profile.js", + "sha256": "715727946299f84b7174c95a84a8fe71e80248edc3a8103af44f1bd5654cd07f" + }, + { + "path": "solution/receipt.js", + "sha256": "15c8135e366b075c258cb6dd3c58faaa1677e1ba3e16b1c1f59b49c11de6186d" + }, + { + "path": "solution/reminder.js", + "sha256": "42fa8c02b7f8b7936b87373d675cfef365b323166185cc4a03c89dbc04747ee4" + }, + { + "path": "solution/status.js", + "sha256": "57d16c3db2885290ad62ca136655eb428da12067cab51bf5d690e15f4d495350" + }, + { + "path": "solution/summary.js", + "sha256": "385f76644fd912f1eeb05ea52e10549bbdd178028754a67e8d099866900b8f51" + }, + { + "path": "verify.sh", + "sha256": "e3a9becb8008af45b1edc8acd71cc67c0d72b00b0479dc0eb1f079d1b19e4270" + } + ] + }, + { + "id": "T06-plan-shaped-fixes", + "taskSha256": "0e2f2fea89e2e4b5bef2f56554c559e8a42a9cd91bea1cd47ed5046184829d5f", + "files": [ + { + "path": "brief.md", + "sha256": "2d32813bcaa5f9dd2c0538a252f0c640c193463a2098d293cd3fa16db77b05ec" + }, + { + "path": "fixtures/csv-parser/index.js", + "sha256": "beec991f2ed86fcfcb8b2cab54bcc0b21c1152a144c1fd0310eec75653b42c66" + }, + { + "path": "fixtures/csv-parser/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "fixtures/duration-formatter/index.js", + "sha256": "bf55e6e2fdc80426cd3f594128c4ba7a27658920706f942a5605f147947da936" + }, + { + "path": "fixtures/duration-formatter/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "fixtures/path-matcher/index.js", + "sha256": "b3b2d942f130fa36d85d70453e992e47565eee296caaade3d1566a71ddf09a65" + }, + { + "path": "fixtures/path-matcher/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "hidden/csv-parser.test.mjs", + "sha256": "6a35fdab80f72c12603db7a749872dd9feb5415c8cf6d4212c27738786fe2aab" + }, + { + "path": "hidden/duration-formatter.test.mjs", + "sha256": "ccaee7348d3fab0718eca96668b3a4ca6acdbe02d4b006c26cd82ade7293aefa" + }, + { + "path": "hidden/path-matcher.test.mjs", + "sha256": "332e84f98304f832f898f62b41d974661742f8a2428a4abdc44f390db6ca6f6c" + }, + { + "path": "mutant/csv-parser/index.js", + "sha256": "0cc19a0f4f466aff818e7b6a86934892617dd636389d1ca397cbb06f3d5e08b1" + }, + { + "path": "mutant/csv-parser/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "mutant/duration-formatter/index.js", + "sha256": "88b4342fc1bf1e3bf5964ea0e07d24f1d10a0691b61a976b9581e091b3464faa" + }, + { + "path": "mutant/duration-formatter/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "mutant/path-matcher/index.js", + "sha256": "b3b2d942f130fa36d85d70453e992e47565eee296caaade3d1566a71ddf09a65" + }, + { + "path": "mutant/path-matcher/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "selftest.sh", + "sha256": "98f8c416e6419c9516311e5c7c46ce33f4c168ca6944f18a383efde547c549ac" + }, + { + "path": "solution/csv-parser/index.js", + "sha256": "0cc19a0f4f466aff818e7b6a86934892617dd636389d1ca397cbb06f3d5e08b1" + }, + { + "path": "solution/csv-parser/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "solution/duration-formatter/index.js", + "sha256": "88b4342fc1bf1e3bf5964ea0e07d24f1d10a0691b61a976b9581e091b3464faa" + }, + { + "path": "solution/duration-formatter/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "solution/path-matcher/index.js", + "sha256": "8e08269bbbd54669027798445cfc6a541823079ed4d8080a5b5c764eddf9e9ef" + }, + { + "path": "solution/path-matcher/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "verify.sh", + "sha256": "fc695c2a5984d9734564c1fb3079d5965905bf312b30f66856fcc2169b75fb37" + } + ] + }, + { + "id": "T07-plan-shaped-features", + "taskSha256": "1d18641a1c5fc4737d6eb81f0e711e6659ef5949b9783a7f517dd08a6606310f", + "files": [ + { + "path": "brief.md", + "sha256": "070a57b628d3b4d7fab47982f35891e24f1ed97ea007473cedc06df3643f8905" + }, + { + "path": "fixtures/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "fixtures/query-string/index.js", + "sha256": "2be310c93a1cf9a8e801c734336ee1030fa32a3ef41ed0c8503e928147bc13d3" + }, + { + "path": "fixtures/semver-range/index.js", + "sha256": "dcad5eb9b016ee4f3f82128abef997e0129555f6c8e2a2167ff96f3215b431d8" + }, + { + "path": "fixtures/text-table/index.js", + "sha256": "cc6fa988162d13b5bf52280f9f21b2d8e70f177319cd4a5a1da14ddc24ca19f2" + }, + { + "path": "mutant/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "mutant/query-string/index.js", + "sha256": "3682e0127489224c839c8233f8dd4912b1e4d9c1c2066ae23244a73b37b6a144" + }, + { + "path": "mutant/semver-range/index.js", + "sha256": "5f8af32e169e587f803bf403f5e596da308a4595d4e4b88d52d249b3e6debddf" + }, + { + "path": "mutant/text-table/index.js", + "sha256": "800f0df53df432c84e68ff6e9ddc994612dcf97e502e76038f7f4cd37eaa89d6" + }, + { + "path": "query-string.test.mjs", + "sha256": "2319c2ac53262312cea23a85ca9ce562cda03026172e4fe29292cd722a003916" + }, + { + "path": "selftest.sh", + "sha256": "fadefd2abe4c1411786694b51e330cfd6e1ea2f4e5c74e9b63e74f3ffa001dc0" + }, + { + "path": "semver-range.test.mjs", + "sha256": "18006f65d2e96acaf7425514fb3aa6245811ede0cd02ac8e7cc1e46cdff2e122" + }, + { + "path": "solution/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "solution/query-string/index.js", + "sha256": "3682e0127489224c839c8233f8dd4912b1e4d9c1c2066ae23244a73b37b6a144" + }, + { + "path": "solution/semver-range/index.js", + "sha256": "5f8af32e169e587f803bf403f5e596da308a4595d4e4b88d52d249b3e6debddf" + }, + { + "path": "solution/text-table/index.js", + "sha256": "6420e06068562914c84942cc50d7aa742d4b44cabb9c7310236a3a6c0e550041" + }, + { + "path": "text-table.test.mjs", + "sha256": "a035b9412c124f7360b01cfb9edc4134b22bf602535becbadf2e162d6325b043" + }, + { + "path": "verify.sh", + "sha256": "04cbb8591265016a4ae042f17cdf9024cb299c230a66429bec8e089cfaee143b" + } + ] + }, + { + "id": "T08-plan-shaped-migrations", + "taskSha256": "19e772dd8ae71fe070192790d09a26c0d564f0eac58bd7ed6e967d3375988ed6", + "files": [ + { + "path": "brief.md", + "sha256": "cfc2db9fa1e2b208fb7c303089a6e2f05a9723641d4603d2f36e0206a5219215" + }, + { + "path": "fixtures/mailer-settings/load-mailer-settings.js", + "sha256": "cf35a9d39184c941b6d83352b56887ed6424ae588464786bd3d0f5300ae92f33" + }, + { + "path": "fixtures/mailer-settings/mailer.env", + "sha256": "7bf8cad45309a8ff3392c10c03a7511bd2a6b8428cc9145a61ab346d76d0c0e3" + }, + { + "path": "fixtures/notifier-settings/config.json", + "sha256": "5bf48a99cb11ea242e6bf66e33a9471abd0d99433ba65cd27edbc66faefc53a6" + }, + { + "path": "fixtures/notifier-settings/load-notifier-settings.js", + "sha256": "9d79829b44abc04972f7a07e80a186387eb11a9b071ae4566e9e6e96cdd21672" + }, + { + "path": "fixtures/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "fixtures/queue-settings/config.json", + "sha256": "101a6d77917d599b66476ea74702f16cc990b7635a8fea96216ed9c21488c763" + }, + { + "path": "fixtures/queue-settings/load-queue-settings.js", + "sha256": "ff7391d7df5d40c018e8ac7b69a907a0acfef041c3e62d3c4dea69053a9c2403" + }, + { + "path": "mailer-settings.test.mjs", + "sha256": "95797eff91278b2139042e5999eee1a065f4072ce6b5ccad09929df8c49c12c9" + }, + { + "path": "mutant/mailer-settings/load-mailer-settings.js", + "sha256": "7a873d73bf13db27347502f85a3b505de89d751a925312122a40dec1f5039da9" + }, + { + "path": "mutant/mailer-settings/mailer.json", + "sha256": "36e99c37c2c304cc055a28e82bc74c8a45d4d1585936ee98a7cb70f823556510" + }, + { + "path": "mutant/notifier-settings/config.json", + "sha256": "090076c740fcd70370c6577df2107546c363f554a8d26ddb63ba22c16e9989a0" + }, + { + "path": "mutant/notifier-settings/load-notifier-settings.js", + "sha256": "9d79829b44abc04972f7a07e80a186387eb11a9b071ae4566e9e6e96cdd21672" + }, + { + "path": "mutant/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "mutant/queue-settings/config.json", + "sha256": "2fed50dfbab4d5a5f8c5a1d029b1144fda1d6e2400229e8bba8e961db897b2f9" + }, + { + "path": "mutant/queue-settings/load-queue-settings.js", + "sha256": "5086628c58237bddc00293c72a86aca3540791510e027bb577678f80b1b588d3" + }, + { + "path": "notifier-settings.test.mjs", + "sha256": "fd8969409a843d733132f13e0a6a53d982d3ed88f4ef1f7e10ccc07134e27fe2" + }, + { + "path": "queue-settings.test.mjs", + "sha256": "b6c9388666df9b174202b9d3710ed188f32758fa3193eb03621a93fdfd529be3" + }, + { + "path": "selftest.sh", + "sha256": "fadefd2abe4c1411786694b51e330cfd6e1ea2f4e5c74e9b63e74f3ffa001dc0" + }, + { + "path": "solution/mailer-settings/load-mailer-settings.js", + "sha256": "7a873d73bf13db27347502f85a3b505de89d751a925312122a40dec1f5039da9" + }, + { + "path": "solution/mailer-settings/mailer.json", + "sha256": "36e99c37c2c304cc055a28e82bc74c8a45d4d1585936ee98a7cb70f823556510" + }, + { + "path": "solution/notifier-settings/config.json", + "sha256": "090076c740fcd70370c6577df2107546c363f554a8d26ddb63ba22c16e9989a0" + }, + { + "path": "solution/notifier-settings/load-notifier-settings.js", + "sha256": "019efce2f900e95cbc99410024d8adf8b89e96d053cee232a79996aa0229b554" + }, + { + "path": "solution/package.json", + "sha256": "3ca9d4afd21425087cf31893b8f9f63c81b0b8408db5e343ca76e5f8aa26ab9a" + }, + { + "path": "solution/queue-settings/config.json", + "sha256": "2fed50dfbab4d5a5f8c5a1d029b1144fda1d6e2400229e8bba8e961db897b2f9" + }, + { + "path": "solution/queue-settings/load-queue-settings.js", + "sha256": "5086628c58237bddc00293c72a86aca3540791510e027bb577678f80b1b588d3" + }, + { + "path": "verify.sh", + "sha256": "8fdebce8b9211b1e8d3455a86db3217568abe5417fb340c7c8a6cbc94cc69bf9" + } + ] } ] } From 318c6107fdd5ff2cbf803eea1f7f0412a028be80 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Thu, 23 Jul 2026 00:23:57 +0800 Subject: [PATCH 10/10] chore: release 0.0.37 --- .claude-plugin/marketplace.json | 8 ++++---- CHANGELOG.md | 7 +++++++ plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/fusion/.claude-plugin/plugin.json | 2 +- plugins/grok/.claude-plugin/plugin.json | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3acce87..769678c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ "email": "hi@okis.dev" }, "description": "Multi-model orchestration marketplace for Claude Code.", - "version": "0.0.36", + "version": "0.0.37", "plugins": [ { "name": "grok", "source": "./plugins/grok", "displayName": "Grok Companion", "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", - "version": "0.0.36", + "version": "0.0.37", "author": { "name": "Harry Yep" }, @@ -34,7 +34,7 @@ "source": "./plugins/codex", "displayName": "Codex Companion", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", - "version": "0.0.36", + "version": "0.0.37", "author": { "name": "Harry Yep" }, @@ -55,7 +55,7 @@ "source": "./plugins/fusion", "displayName": "Fusion Orchestrator", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", - "version": "0.0.36", + "version": "0.0.37", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index beaa2bf..0d6ddd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # changelog +## 0.0.37 + +- settlement and telemetry become more discriminating: batched settlement validates every pair before writing, reports per pair rejection reasons, and `/fusion:stats` adds lane drift, per SKU trends, narrow wave watch, and an acceptance epoch cutoff; the worker lifecycle adds a parent context advisory, package type and brief byte capture, recalibrated trivial worker budgets, and token observations, while the inline guard gains tail allowance and softens zero dispatches +- repeated advisory noise is reduced across the lanes: in flight stop notices dedupe, the Grok token observer gains monitor silence, and the Codex monitor stays silent when no announcement is needed; verification also surfaces manifest lint advisories and codifies wave settlement and dispatch norms +- Codex now warns when Sol runs in the foreground, surfaces timeout resume state, and records `repositoryTopLevel`; Grok reports permission denial detail and the same repository identity, with peer wrapper completion contract canaries covering the delivery gate +- the bench suite completes all eight tasks, closing the release's orchestration, observability, transport, verification, and lifecycle coverage + ## 0.0.36 - peer transport wrappers get their own completion contract: 0.0.35's SubagentStop matcher expansion routed `codex:codex-rescue`, `grok:grok-rescue`, and `grok:grok-review-runner` through the claude worker deliverable gate, whose `verification` contract demands a final message ending in `delivery: complete` plus `verification: passed`; a verbatim companion relay ends with the engine envelope (`job:`, `state:`) instead, so every foreground peer delivery burned its single truncation recovery retry arguing with the gate, took a false `failureKind: delivery`, and terminalized as `incomplete`, which the notification reconcile guard skips, breaking notification driven auto collection and forcing a stop collection block on every peer package (observed two of two in the first 0.0.35 session against twenty nine of twenty nine clean on 0.0.34 the same day); dispatches now record a `transport` contract, `completedReport` accepts a relay whose footer parses through `peerJobIdFromCollectedResult` (the same parser settlement identity backfill uses, with `grok:grok-review-runner` always deliverable because its terminal output may be raw json validated by the review consumer), a matching transport retry message covers genuinely truncated relays, and the guards also match the agent type so records created before the fix settle correctly after a hot deploy diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index d16794c..4911700 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "codex", "displayName": "Codex Companion", - "version": "0.0.36", + "version": "0.0.37", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/.claude-plugin/plugin.json b/plugins/fusion/.claude-plugin/plugin.json index e6e31c6..363c9ea 100644 --- a/plugins/fusion/.claude-plugin/plugin.json +++ b/plugins/fusion/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "fusion", "displayName": "Fusion Orchestrator", - "version": "0.0.36", + "version": "0.0.37", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", "author": { "name": "Harry Yep" diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index b24ae49..bdc9ed4 100644 --- a/plugins/grok/.claude-plugin/plugin.json +++ b/plugins/grok/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "grok", "displayName": "Grok Companion", - "version": "0.0.36", + "version": "0.0.37", "description": "Local Grok CLI delegation: task, review, resumable history, background jobs, stats, and setup health checks.", "author": { "name": "Harry Yep"