diff --git a/scripts/dev-server.js b/scripts/dev-server.js index bda0af30b..8e39a0476 100644 --- a/scripts/dev-server.js +++ b/scripts/dev-server.js @@ -39,10 +39,24 @@ const watchEnabled = const watchedRoots = [ resolve(root, "packages", "daemon", "src"), resolve(root, "packages", "daemon", "package.json"), + // coding-agent hosts the rpc-mode child the server spawns; edits here + // must trigger a restart to make sf_feedback / start_autonomous handlers + // reflect new code. + resolve(root, "packages", "coding-agent", "src"), resolve(root, "scripts", "dev-server.js"), resolve(root, "scripts", "copy-resources.cjs"), resolve(root, "scripts", "ensure-source-resources.cjs"), resolve(root, "package.json"), + // SF extension dist stamp — copy-resources updates this atomically once + // src/resources/extensions/sf changes are built. Watching the stamp file + // (not the dist tree) avoids a heavy recursive walk while still picking + // up extension upgrades the moment they land. + resolve(root, "dist", "resources", ".sf-resource-build-stamp"), + // Git HEAD — changes on pull / branch switch / commit. Catches "upgrade" + // flows where source code changed outside this process (operator pulled + // a remote update). Combined with the build stamp, this closes the + // "automatic upgrade detection" gap without git polling. + resolve(root, ".git", "HEAD"), ]; function newestMtimeMs(path) { diff --git a/src/resources/extensions/sf/tests/auto-timers-runaway-failure.test.mjs b/src/resources/extensions/sf/tests/auto-timers-runaway-failure.test.mjs new file mode 100644 index 000000000..5e48a867c --- /dev/null +++ b/src/resources/extensions/sf/tests/auto-timers-runaway-failure.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "vitest"; +import { finalizeRunawayGuardFailure } from "../auto-timers.js"; +import { readUnitRuntimeRecord } from "../uok/unit-runtime.js"; + +const tmpRoots = []; + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const root = mkdtempSync(join(tmpdir(), "sf-auto-timers-")); + tmpRoots.push(root); + return root; +} + +test("zero_progress_fail_closes_lineage_clears_timers_and_unblocks_unit", async () => { + const root = makeProject(); + const startedAt = Date.now(); + const handles = { + unitTimeoutHandle: setTimeout(() => {}, 60_000), + wrapupWarningHandle: setTimeout(() => {}, 60_000), + idleWatchdogHandle: setInterval(() => {}, 60_000), + continueHereHandle: setInterval(() => {}, 60_000), + }; + const s = { + basePath: root, + currentUnit: { type: "challenge", id: "M048/S04/challenge", startedAt }, + currentUnitModel: { provider: "minimax", id: "MiniMax-M2.7" }, + ...handles, + }; + const messages = []; + const blocked = []; + const feedback = []; + const resolved = []; + + await finalizeRunawayGuardFailure( + { + s, + unitType: "challenge", + unitId: "M048/S04/challenge", + buildSnapshotOpts: () => ({ traceId: "trace-1" }), + ctx: { + sessionManager: { getSessionId: () => "worker-session-1" }, + ui: { + notify(message, level) { + messages.push({ message, level }); + }, + }, + }, + }, + { + reason: "zero-progress", + metadata: { zeroProgress: true }, + }, + { + async closeoutUnit() {}, + blockModel(basePath, provider, id, reason) { + blocked.push({ basePath, provider, id, reason }); + }, + recordSelfFeedback(entry) { + feedback.push(entry); + }, + resolveAgentEnd(event) { + resolved.push(event); + }, + }, + ); + + const record = readUnitRuntimeRecord(root, "challenge", "M048/S04/challenge"); + assert.equal(record.status, "failed"); + assert.equal(record.phase, "failed-silent-worker"); + assert.equal(record.lineage.status, "failed"); + assert.equal(record.lineage.currentWorkerSessionId, null); + assert.deepEqual(record.lineage.failedWorkerSessionIds, ["worker-session-1"]); + assert.equal(s.unitTimeoutHandle, null); + assert.equal(s.wrapupWarningHandle, null); + assert.equal(s.idleWatchdogHandle, null); + assert.equal(s.continueHereHandle, null); + assert.equal(blocked.length, 1); + assert.equal(blocked[0].provider, "minimax"); + assert.equal(feedback.length, 1); + assert.equal(feedback[0].kind, "runaway-loop:silent-worker-failure"); + assert.equal(resolved.length, 1); + assert.equal(resolved[0]._synthetic, "runaway-guard-fail"); + assert.ok(messages.some((m) => m.level === "error")); +}); diff --git a/src/resources/extensions/sf/tests/experimental-flags.test.mjs b/src/resources/extensions/sf/tests/experimental-flags.test.mjs new file mode 100644 index 000000000..c699ee01b --- /dev/null +++ b/src/resources/extensions/sf/tests/experimental-flags.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { afterEach, test } from "vitest"; +import { setExperimentalFlag } from "../experimental.js"; +import { parsePreferencesYaml } from "../preferences-loader.js"; + +const originalCwd = process.cwd(); +const originalEnv = { ...process.env }; +const tmpRoots = []; + +afterEach(() => { + process.chdir(originalCwd); + process.env = { ...originalEnv }; + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject(content) { + const root = mkdtempSync(join(tmpdir(), "sf-experimental-")); + tmpRoots.push(root); + const project = join(root, "project"); + const home = join(root, "home"); + mkdirSync(join(project, ".sf"), { recursive: true }); + mkdirSync(join(home, ".sf"), { recursive: true }); + writeFileSync(join(project, ".sf", "preferences.yaml"), content, "utf-8"); + process.env.HOME = home; + process.env.SF_HOME = join(home, ".sf"); + process.chdir(project); + return project; +} + +test("setExperimentalFlag_writes_single_yaml_document_without_frontmatter_markers", () => { + const project = makeProject( + [ + "version: 1", + "experimental:", + " smoke_gate: true", + "", + "# SF Preferences", + "", + "See `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full documentation.", + "", + ].join("\n"), + ); + + setExperimentalFlag("smoke_gate", false); + + const written = readFileSync( + join(project, ".sf", "preferences.yaml"), + "utf-8", + ); + assert.equal(written.startsWith("---"), false); + assert.equal((written.match(/^---$/gm) ?? []).length, 0); + assert.equal(parse(written).experimental.smoke_gate, false); + assert.match(written, /# SF Preferences/); +}); + +test("parsePreferencesYaml_when_legacy_raw_reference_body_exists_reads_machine_yaml", () => { + const parsed = parsePreferencesYaml( + [ + "version: 1", + "experimental:", + " smoke_gate: false", + "", + "# SF Preferences", + "", + "See `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full documentation.", + "", + ].join("\n"), + ); + + assert.equal(parsed.experimental.smoke_gate, false); +});