diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 06983eaa71fd..d7e17b2652ed 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,16 +1,24 @@ -import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; +import { defineConfig, type CodeRabbitConfig, type CodeRabbitContext } from "@coderabbitai/config"; + +// The SDK requires unexported enums here; these literals match the service's configuration schema. +type KnowledgeBase = NonNullable; +const localScope = "local" as NonNullable["scope"]; +const disabledIntegration = "disabled" as NonNullable["usage"]; const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); export default defineConfig((ctx) => ({ + inheritance: false, reviews: { high_level_summary: false, review_status: false, request_changes_workflow: false, + related_issues: false, + related_prs: false, allow_author_approval: !isGated(ctx), auto_review: { - enabled: true, + enabled: false, }, pre_merge_checks: { docstrings: { mode: "off" }, @@ -29,6 +37,15 @@ export default defineConfig((ctx) => ({ ], }, knowledge_base: { + automatic_linking_mode: "disabled", + linked_repositories: [], + issues: { scope: localScope }, + pull_requests: { scope: localScope }, + learnings: { scope: localScope }, + jira: { usage: disabledIntegration }, + linear: { usage: disabledIntegration }, + mcp: { usage: disabledIntegration }, + web_search: { enabled: false }, code_guidelines: { filePatterns: [ { files: "docs/internals/effect-services.md", applyTo: "{apps,packages,infra}/**/*.ts" }, diff --git a/README.md b/README.md index 8aa78f81b9ad..70973d282df5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> Community native model-free tick preview for T3 Code v0.1.2: [overview](docs/community-patches/native-tick/README.md), [setup](docs/community-patches/native-tick/INSTALL.md), and [source-patch download](https://github.com/zainmitchell/t3code/releases/tag/community-native-tick-v0.1.2-preview.1). Independent fork; the upstream project README follows. + # T3 Code T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). diff --git a/apps/server/src/commandJobs/CommandJobs.integration.test.ts b/apps/server/src/commandJobs/CommandJobs.integration.test.ts new file mode 100644 index 000000000000..1d08e8bffe73 --- /dev/null +++ b/apps/server/src/commandJobs/CommandJobs.integration.test.ts @@ -0,0 +1,807 @@ +/** + * Command jobs against the real scheduler service, thread launch, thread management and + * orchestrator on an in-memory database, running a real child process. Git, projects and the + * provider are the faked boundary: the provider refuses to open a session, so any model turn the + * job caused would fail loudly. + */ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ScheduledTaskId, + type ScheduledTask, + type ScheduledTaskUpsertInput, + ThreadId, +} from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as WorktreeSetupTracker from "../project/WorktreeSetupTracker.ts"; +import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; +import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as CommandJobs from "./CommandJobs.ts"; + +const projectId = ProjectId.make("project:factory"); +const otherProjectId = ProjectId.make("project:other"); +const builder = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", + options: [{ id: "reasoningEffort", value: "high" }], +}; +const baseProject = { + id: projectId, + title: "Factory", + workspaceRoot: "/repo", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: builder, + defaultThreadEnvMode: null, + scripts: [], + createdAt: "2026-10-22T00:00:00.000Z", + updatedAt: "2026-10-22T00:00:00.000Z", + deletedAt: null, +} as const; + +const JOB_SCRIPT = ` +const fs = require("node:fs"); +const plan = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const input = JSON.parse(fs.readFileSync(process.env.T3_JOB_INPUT, "utf8")); +fs.appendFileSync(plan.log, JSON.stringify(input.workers) + "\\n"); +if (plan.output) fs.writeFileSync(process.env.T3_JOB_OUTPUT, JSON.stringify(plan.output)); +`; + +const makeLayer = ( + stateDir: string, + openedSessions: Array, + processes: Layer.Layer< + ProcessRunner.ProcessRunner, + never, + ChildProcessSpawner.ChildProcessSpawner + > = ProcessRunner.layer, +) => { + // setup() creates this repository beside the state directory. + const workspaceRoot = `${stateDir}/../repo`; + const project = { ...baseProject, workspaceRoot }; + const adapter = { + instanceId: builder.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: (input: unknown) => { + openedSessions.push(JSON.stringify(input)); + return Effect.die("a command job must not open a provider session"); + }, + } as unknown as ProviderAdapter.ProviderAdapterV2["Service"]; + const layerDatabase = SqlitePersistence.layerMemory; + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "command-jobs" }, + layerRegistry, + // The effect worker runs, so any provider turn a launch requested would reach the adapter. + { databaseLayer: layerDatabase, runEffectWorker: true }, + ); + const layerServerSettings = ServerSettings.layerTest(); + const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); + const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerServerSettings), + Layer.provide(layerReceipts), + ); + const layerExternal = Layer.mergeAll( + WorktreeSetupTracker.layer, + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ get: () => Effect.succeed(null) }), + Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void }), + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed( + id === projectId + ? Option.some(project) + : id === otherProjectId + ? Option.some({ ...project, id: otherProjectId, workspaceRoot: "/other" }) + : Option.none(), + ), + getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(GitWorkflow.GitWorkflowService)({ + hasCommit: (input) => + Effect.succeed(input.refName === "main" || /^[0-9a-f]{40}$/.test(input.refName)), + hasRefNamed: () => Effect.succeed(false), + createWorktree: (input) => + Effect.succeed({ + worktree: { + path: `/repo-worktrees/${input.newRefName}`, + refName: input.newRefName, + headSha: "abc", + }, + } as never), + removeWorktree: () => Effect.void, + }), + Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }), + Layer.mock(TextGeneration.TextGeneration)({}), + layerServerSettings, + ProviderRegistryMock.layer(), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + folderForThread: () => Effect.succeed(Option.none()), + }), + ); + const layerLaunch = ThreadLaunch.layer.pipe( + Layer.provide( + Layer.mergeAll(layerExternal, layerThreadManagement, layerReceipts, IdAllocator.layer), + ), + ); + const layerCommandJobs = CommandJobs.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerLaunch, + layerThreadManagement, + processes, + layerExternal, + NodeCrypto.layer, + Layer.succeed(ServerConfig.ServerConfig, { stateDir } as never), + ), + ), + ); + const layerScheduledTasks = ScheduledTasks.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerLaunch, + layerThreadManagement, + layerExternal, + layerCommandJobs, + layerDatabase, + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(SecretRequests.SecretRequests)({}), + ), + ), + ); + return Layer.mergeAll( + layerScheduledTasks, + layerThreadManagement, + layerLaunch, + layerDatabase, + ).pipe(Layer.provideMerge(NodeServices.layer)); +}; + +/** Runs git for fixture repositories, returning trimmed stdout. */ +const git = (cwd: string, ...args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("git", ["-c", "user.name=t", "-c", "user.email=t@localhost", ...args], { + cwd, + stderr: "ignore", + }), + ); + const out = yield* child.stdout.pipe(Stream.decodeText(), Stream.mkString); + yield* child.exitCode; + return out.trim(); + }).pipe(Effect.scoped, Effect.orDie); + +const setup = Effect.fn("setup")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "command-jobs-int-" }); + const stateDir = path.join(root, "userdata"); + const fixtureDir = path.join(root, "fixture"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.makeDirectory(fixtureDir, { recursive: true }); + const script = path.join(fixtureDir, "job.cjs"); + const planPath = path.join(fixtureDir, "plan.json"); + const logPath = path.join(fixtureDir, "runs.ndjson"); + yield* fs.writeFileString(script, JOB_SCRIPT); + const repo = path.join(root, "repo"); + yield* fs.makeDirectory(repo, { recursive: true }); + yield* git(repo, "init", "-q", "-b", "main"); + yield* git(repo, "commit", "--allow-empty", "-q", "-m", "base"); + const baseCommit = yield* git(repo, "rev-parse", "HEAD"); + yield* fs.writeFileString( + path.join(stateDir, CommandJobs.COMMAND_JOBS_CONFIG_FILE), + JSON.stringify({ + jobs: { + tick: { + projectId, + taskIds: ["task:factory-tick", "task:a-job"], + executable: process.execPath, + args: [script, planPath], + cwd: fixtureDir, + timeoutMs: 30_000, + launch: { + baseRef: "main", + branchPrefix: "factory/", + models: { builder }, + runtimeMode: "auto-accept-edits", + }, + }, + }, + }), + ); + const receiptsPath = path.join(stateDir, "command-jobs", "tick", "receipts.json"); + return { + stateDir, + repo, + baseCommit, + receiptsPath, + writePlan: (output?: unknown) => + fs.writeFileString(planPath, JSON.stringify({ log: logPath, output })), + runs: fs.readFileString(logPath).pipe( + Effect.map((text) => + text + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as Array<{ workId: string; threadId: string | null; state: string }>, + ), + ), + ), + ranAtAll: fs.exists(logPath), + readReceipts: fs + .readFileString(receiptsPath) + .pipe( + Effect.map( + (text) => JSON.parse(text).receipts as Array<{ workId: string; threadId: string | null }>, + ), + ), + writeReceipts: (receipts: unknown) => + fs.writeFileString(receiptsPath, JSON.stringify({ receipts })), + }; +}); + +/** Runs the task the way "Run now" does and waits for the run it started to be recorded. */ +const runNowAndSettle = Effect.fn("runNowAndSettle")(function* (task: ScheduledTask) { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const before = (yield* tasks.list()).tasks.find( + (candidate) => candidate.id === task.id, + )!.runCount; + const settled = yield* tasks.subscribeList().pipe( + Stream.map(({ tasks: current }) => current.find((candidate) => candidate.id === task.id)), + Stream.filter( + (current) => + current !== undefined && current.runCount > before && current.lastRunStatus !== "running", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* tasks.runNow({ id: task.id }); + return Option.getOrThrow(yield* Fiber.join(settled))!; +}); + +const createTask = Effect.fn("createTask")(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("task:factory-tick"), + title: "Factory tick", + prompt: "command-job:tick", + enabled: false, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + workspaceStrategy: { type: "root" }, + modelSelection: builder, + runtimeMode: "full-access", + interactionMode: "default", + }); + return task; +}); + +it.live("an idle tick runs the command and makes no thread, turn or provider call", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + return yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const task = yield* createTask(); + yield* fixture.writePlan(); + const settled = yield* runNowAndSettle(task); + assert.equal(settled.lastRunStatus, "succeeded", settled.lastRunError ?? ""); + assert.lengthOf(yield* fixture.runs, 1); + assert.lengthOf(yield* threads.listProjectThreads({ projectId, includeSubagents: true }), 0); + assert.deepEqual(openedSessions, []); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +it.live("launches, messages and recovers workers through the real thread services", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + return yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const launches = yield* ThreadLaunch.ThreadLaunchService; + const task = yield* createTask(); + const request = { + type: "launch", + workId: "issue-7-build", + title: "Build issue 7", + prompt: "Implement issue 7 and record the result.", + branch: "factory/issue-7", + model: "builder", + }; + yield* fixture.writePlan({ requests: [request] }); + const first = yield* runNowAndSettle(task); + assert.equal(first.lastRunStatus, "succeeded", first.lastRunError ?? ""); + + const [worker] = yield* threads.listProjectThreads({ projectId, includeSubagents: true }); + assert.isDefined(worker); + const shell = yield* threads.getThreadShell(worker!.id); + assert.equal(shell?.title, "Build issue 7"); + assert.equal(shell?.branch, "factory/issue-7"); + assert.equal(shell?.runtimeMode, "auto-accept-edits"); + assert.deepEqual(shell?.modelSelection, builder); + assert.equal(shell?.createdBy, "system"); + const projection = yield* threads.getThreadProjection(worker!.id); + // The base the real launch prepared from is the commit the receipt pins. + assert.deepEqual(projection.runs[0]?.workspacePreparation, { + type: "worktree", + baseRef: fixture.baseCommit, + branch: "factory/issue-7", + startFromOrigin: false, + }); + const launched = JSON.parse( + yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.readFileString(fixture.receiptsPath)), + ), + ).receipts[0]; + assert.equal(launched.launch.resolvedBaseCommit, fixture.baseCommit); + assert.deepEqual(launched.launch.effective, { + providerInstanceId: "codex", + modelSelection: builder, + runtimeMode: "auto-accept-edits", + interactionMode: "default", + }); + assert.equal(projection.messages[0]?.text, request.prompt); + assert.equal(projection.messages[0]?.scheduledTaskId, task.id); + assert.deepEqual( + (yield* fixture.readReceipts).map((receipt) => [receipt.workId, receipt.threadId]), + [["issue-7-build", worker!.id]], + ); + + // A review follow-up, a repeated launch of the same work and a repeated send. + const send = { + type: "send", + workId: "issue-7-build", + sendId: "review-1", + text: "Fix review finding 1.", + }; + yield* fixture.writePlan({ requests: [request, send] }); + const second = yield* runNowAndSettle(task); + assert.equal(second.lastRunStatus, "succeeded", second.lastRunError ?? ""); + const messagesAfterSend = yield* threads.getMessageCount(worker!.id); + yield* fixture.writePlan({ requests: [send] }); + yield* runNowAndSettle(task); + assert.equal(yield* threads.getMessageCount(worker!.id), messagesAfterSend); + + // Crash after the orchestrator accepted a launch but before its receipt recorded the + // thread, plus a launch whose intent was written but never reached the orchestrator. + yield* fixture.writeReceipts([ + { ...(yield* fixture.readReceipts)[0]!, threadId: null }, + { + ...(yield* fixture.readReceipts)[0]!, + workId: "never-started", + threadId: null, + request: { ...request, workId: "never-started", branch: "factory/never" }, + }, + ]); + yield* fixture.writePlan(); + const recovered = yield* runNowAndSettle(task); + assert.equal(recovered.lastRunStatus, "succeeded", recovered.lastRunError ?? ""); + assert.deepEqual( + (yield* fixture.readReceipts).map((receipt) => [receipt.workId, receipt.threadId]), + [["issue-7-build", worker!.id]], + ); + assert.lengthOf(yield* threads.listProjectThreads({ projectId, includeSubagents: true }), 1); + const lastSnapshot = (yield* fixture.runs).at(-1)!; + assert.deepEqual( + lastSnapshot.map((entry) => [entry.workId, entry.threadId]), + [["issue-7-build", worker!.id]], + ); + + // The command receipt is what makes the replay safe: the same id never makes a second thread. + const replay = yield* launches.launch({ + commandId: CommandId.make("command-job:tick:issue-7-build"), + projectId, + title: "Build issue 7", + modelSelection: builder, + runtimeMode: "auto-accept-edits", + interactionMode: "default", + workspaceStrategy: { + type: "worktree", + baseRef: "main", + branch: "factory/issue-7", + startFromOrigin: false, + }, + createdBy: "system", + creationSource: "server", + }); + assert.equal(replay.threadId, worker!.id); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +it.live( + "Run now refuses a bound task moved to another project or lowered below the ceiling", + () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const threads = yield* ThreadManagement.ThreadManagementService; + const base = { + prompt: "command-job:tick", + enabled: false, + schedule: { type: "interval" as const, everyMs: 60_000 }, + workspaceStrategy: { type: "root" as const }, + modelSelection: builder, + interactionMode: "default" as const, + }; + // Both ids are bound to the job, so only the project and permission checks can refuse them. + const { task: elsewhere } = yield* tasks.upsert({ + ...base, + id: ScheduledTaskId.make("task:factory-tick"), + title: "Moved", + projectId: otherProjectId, + runtimeMode: "full-access", + }); + const { task: lowered } = yield* tasks.upsert({ + ...base, + id: ScheduledTaskId.make("task:a-job"), + title: "Lowered", + projectId, + runtimeMode: "approval-required", + }); + yield* fixture.writePlan({ + requests: [ + { + type: "launch", + workId: "w1", + title: "Work", + prompt: "Do the work.", + branch: "factory/w1", + model: "builder", + }, + ], + }); + const moved = yield* runNowAndSettle(elsewhere); + assert.equal(moved.lastRunStatus, "failed"); + assert.include(moved.lastRunError ?? "", `not "${projectId}"`); + const below = yield* runNowAndSettle(lowered); + assert.equal(below.lastRunStatus, "failed"); + assert.include(below.lastRunError ?? "", "is below the job's launch ceiling"); + // Nothing ran: no command pass, no thread, no provider session. + assert.isFalse(yield* fixture.ranAtAll); + for (const id of [projectId, otherProjectId]) + assert.lengthOf( + yield* threads.listProjectThreads({ projectId: id, includeSubagents: true }), + 0, + ); + assert.deepEqual(openedSessions, []); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }, +); + +it.live("a task naming an unconfigured job fails instead of falling back to a model turn", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const threads = yield* ThreadManagement.ThreadManagementService; + const { task } = yield* tasks.upsert({ + title: "Unknown job", + prompt: "command-job:missing", + enabled: false, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + workspaceStrategy: { type: "root" }, + modelSelection: builder, + runtimeMode: "full-access", + interactionMode: "default", + }); + const settled = yield* runNowAndSettle(task); + assert.equal(settled.lastRunStatus, "failed"); + assert.include(settled.lastRunError ?? "", 'Command job "missing" is not configured.'); + assert.lengthOf(yield* threads.listProjectThreads({ projectId, includeSubagents: true }), 0); + assert.deepEqual(openedSessions, []); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +/** Holds every job process open until released, so a test can act mid-pass. */ +const gatedProcesses = Effect.gen(function* () { + const started = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + const count = yield* Ref.make(0); + const interrupted = yield* Deferred.make(); + // Only the job's own command is held and counted; the job's git base lookups run for real. + const layer = Layer.effect( + ProcessRunner.ProcessRunner, + Effect.map(ProcessRunner.make(), (real) => ({ + run: (input: ProcessRunner.ProcessRunInput) => + input.command !== process.execPath + ? real.run(input) + : Ref.updateAndGet(count, (n) => n + 1).pipe( + Effect.tap((n) => Queue.offer(started, n)), + Effect.andThen(Deferred.await(release)), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + Effect.as({ + stdout: "", + stderr: "", + code: 0 as never, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + ), + })), + ); + return { started, release, count, interrupted, layer }; +}); + +const settledRun = (taskId: ScheduledTaskId, minRuns: number) => + Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + return yield* tasks.subscribeList().pipe( + Stream.map(({ tasks: current }) => current.find((candidate) => candidate.id === taskId)), + Stream.filter( + (current) => + current !== undefined && + current.runCount >= minRuns && + current.lastRunStatus !== "running", + ), + Stream.runHead, + Effect.map((found) => Option.getOrThrow(found)!), + Effect.forkChild, + ); + }); + +it.effect("a long job does not hold up other due tasks, and Run now coalesces into it", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + const gate = yield* gatedProcesses; + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const base = { + enabled: true, + schedule: { type: "interval" as const, everyMs: 60_000 }, + projectId, + workspaceStrategy: { type: "root" as const }, + modelSelection: builder, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + }; + const { task: job } = yield* tasks.upsert({ + ...base, + id: ScheduledTaskId.make("task:a-job"), + title: "Tick", + prompt: "command-job:tick", + }); + const { task: agent } = yield* tasks.upsert({ + ...base, + id: ScheduledTaskId.make("task:b-agent"), + title: "Audit", + prompt: "Audit the repository.", + }); + const agentSettled = yield* settledRun(agent.id, 1); + const jobSettled = yield* settledRun(job.id, 1); + yield* TestClock.adjust("65 seconds"); + assert.equal(yield* Queue.take(gate.started), 1); + // The agent task launched while the job's first pass is still open. + const agentRun = yield* Fiber.join(agentSettled); + assert.equal(agentRun.lastRunStatus, "succeeded", agentRun.lastRunError ?? ""); + const midPass = (yield* tasks.list()).tasks.find((candidate) => candidate.id === job.id); + assert.equal(midPass?.lastRunStatus, "running"); + yield* tasks.runNow({ id: job.id }); + yield* Deferred.succeed(gate.release, undefined); + const jobRun = yield* Fiber.join(jobSettled); + assert.equal(jobRun.lastRunStatus, "succeeded", jobRun.lastRunError ?? ""); + // One recorded run, two passes: the first plus the coalesced Run now. + assert.equal(jobRun.runCount, 1); + assert.equal(yield* Ref.get(gate.count), 2); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions, gate.layer))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +it.effect("pausing a running job's task stops it", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + const gate = yield* gatedProcesses; + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const task = yield* createTask(); + yield* tasks.setEnabled({ id: task.id, enabled: true }); + const settled = yield* settledRun(task.id, 1); + yield* tasks.runNow({ id: task.id }); + yield* Queue.take(gate.started); + yield* tasks.setEnabled({ id: task.id, enabled: false }); + const stopped = yield* Fiber.join(settled); + assert.equal(stopped.lastRunStatus, "failed"); + assert.include(stopped.lastRunError ?? "", "Stopped."); + assert.isFalse(stopped.enabled); + assert.equal(yield* Ref.get(gate.count), 1); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions, gate.layer))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +it.effect("pausing or deleting a task the job is not bound to does not stop it", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + const gate = yield* gatedProcesses; + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const task = yield* createTask(); + const settled = yield* settledRun(task.id, 1); + yield* tasks.runNow({ id: task.id }); + yield* Queue.take(gate.started); + // Anyone allowed to schedule tasks can point one at the job; it must not reach the pass. + const { task: intruder } = yield* tasks.upsert({ + title: "Not bound", + prompt: "command-job:tick", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + workspaceStrategy: { type: "root" }, + modelSelection: builder, + runtimeMode: "full-access", + interactionMode: "default", + }); + yield* tasks.setEnabled({ id: intruder.id, enabled: false }); + yield* tasks.delete({ id: intruder.id }); + yield* Deferred.succeed(gate.release, undefined); + const run = yield* Fiber.join(settled); + assert.equal(run.lastRunStatus, "succeeded", run.lastRunError ?? ""); + assert.equal(yield* Ref.get(gate.count), 1); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions, gate.layer))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +/** The upsert a client form or MCP `update_scheduled_task` sends to edit an existing task. */ +const editTask = (task: ScheduledTask, changes: Partial) => + Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + return yield* tasks.upsert({ + id: task.id, + title: task.title, + prompt: task.prompt, + enabled: task.enabled, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: task.projectId, + threadId: task.threadId, + workspaceStrategy: task.workspaceStrategy, + modelSelection: task.modelSelection, + runtimeMode: task.runtimeMode, + interactionMode: task.interactionMode, + ...changes, + }); + }); + +it.effect.each([ + { name: "disabling", changes: { enabled: false } }, + { name: "pointing it away from its job", changes: { prompt: "Audit the repository." } }, + { name: "pointing it at another job", changes: { prompt: "command-job:other" } }, + { name: "moving it to another project", changes: { projectId: otherProjectId } }, +])("an edit $name stops the task's running job", ({ changes }) => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + const gate = yield* gatedProcesses; + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const task = yield* createTask(); + const enabled = (yield* tasks.setEnabled({ id: task.id, enabled: true })).task; + const settled = yield* settledRun(task.id, 1); + yield* tasks.runNow({ id: task.id }); + yield* Queue.take(gate.started); + yield* editTask(enabled, changes); + if (changes.projectId !== undefined) yield* Deferred.succeed(gate.release, undefined); + const run = yield* Fiber.join(settled); + assert.equal(run.lastRunStatus, "failed"); + assert.include(run.lastRunError ?? "", "Stopped."); + assert.equal(yield* Ref.get(gate.count), 1); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions, gate.layer))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +it.effect("an edit that leaves the job running or edits an unbound task does not stop it", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + const gate = yield* gatedProcesses; + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const task = yield* createTask(); + const enabled = (yield* tasks.setEnabled({ id: task.id, enabled: true })).task; + const settled = yield* settledRun(task.id, 1); + yield* tasks.runNow({ id: task.id }); + yield* Queue.take(gate.started); + // Renaming the bound task keeps it on its job. + yield* editTask(enabled, { title: "Renamed tick" }); + // Editing a task the job is not bound to reaches nothing, whatever it changes. + const { task: intruder } = yield* tasks.upsert({ + title: "Not bound", + prompt: "command-job:tick", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + workspaceStrategy: { type: "root" }, + modelSelection: builder, + runtimeMode: "full-access", + interactionMode: "default", + }); + yield* editTask(intruder, { enabled: false }); + yield* editTask({ ...intruder, enabled: true }, { prompt: "Something else." }); + yield* Deferred.succeed(gate.release, undefined); + const run = yield* Fiber.join(settled); + assert.equal(run.lastRunStatus, "succeeded", run.lastRunError ?? ""); + assert.equal(yield* Ref.get(gate.count), 1); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions, gate.layer))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + +it.effect("deleting the thread a job's task is bound to stops its running job", () => { + const openedSessions: Array = []; + return Effect.gen(function* () { + const fixture = yield* setup(); + const gate = yield* gatedProcesses; + return yield* Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const threadId = ThreadId.make("thread:factory-manager"); + const task = yield* createTask(); + // schedule_task called from a thread binds the task to it by default. + yield* editTask(task, { threadId, enabled: true }); + yield* tasks.runNow({ id: task.id }); + yield* Queue.take(gate.started); + yield* tasks.deleteForThread(threadId); + // The row is gone, so the stop shows only as the pass process being interrupted. + yield* Deferred.await(gate.interrupted); + assert.lengthOf((yield* tasks.list()).tasks, 0); + assert.equal(yield* Ref.get(gate.count), 1); + }).pipe(Effect.provide(makeLayer(fixture.stateDir, openedSessions, gate.layer))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); diff --git a/apps/server/src/commandJobs/CommandJobs.test.ts b/apps/server/src/commandJobs/CommandJobs.test.ts new file mode 100644 index 000000000000..7e1f357b1c29 --- /dev/null +++ b/apps/server/src/commandJobs/CommandJobs.test.ts @@ -0,0 +1,1503 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type OrchestrationV2DomainEvent, + ProjectId, + ProviderInstanceId, + type ScheduledTask, + ScheduledTaskId, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as DispatchModeLimit from "../orchestration-v2/DispatchModeLimit.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as CommandJobs from "./CommandJobs.ts"; + +const projectId = ProjectId.make("project:factory"); +const isReceiptReadError = Schema.is(CommandReceiptStore.CommandReceiptStoreReadError); +const windowsHost = HostProcess.Platform.defaultValue() === "win32"; +const builder = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + +const task: ScheduledTask = { + id: ScheduledTaskId.make("task:tick"), + title: "Factory tick", + prompt: "command-job:tick", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: builder, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + createdAt: "2026-10-22T00:00:00.000Z", + updatedAt: "2026-10-22T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, +}; + +/** + * The job under test: records what it saw, then does what its plan file says. It runs as a real + * child of the test process, started the same way the server starts a configured job. + */ +const JOB_SCRIPT = ` +const fs = require("node:fs"); +const { spawn } = require("node:child_process"); +const plan = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const raw = fs.readFileSync(process.env.T3_JOB_INPUT); +const input = JSON.parse(raw.toString("utf8")); +fs.appendFileSync(plan.log, JSON.stringify({ + argv: process.argv.slice(3), + runId: process.env.T3_JOB_RUN_ID, + inputBytes: raw.length, + workers: input.workers.map((worker) => worker.workId), + previousRejected: input.previousRejected.length, + baseCommit: input.scope ? input.scope.baseCommit : null, +}) + "\\n"); +// Moves the base ref while the pass is running, before any request reaches the server. +if (plan.moveRef) require("node:child_process").execFileSync("git", ["-C", plan.moveRef, "-c", "user.name=t", "-c", "user.email=t@localhost", "commit", "--allow-empty", "-q", "-m", "moved"]); +if (plan.pidFile) fs.writeFileSync(plan.pidFile, String(process.pid)); +if (plan.grandchild) { + const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 600000)"], { stdio: "ignore" }); + fs.writeFileSync(plan.grandchild, String(child.pid)); +} +if (plan.sleepMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, plan.sleepMs); +if (plan.output !== undefined) fs.writeFileSync(process.env.T3_JOB_OUTPUT, Buffer.from(plan.output, "base64")); +if (plan.stderr) process.stderr.write(plan.stderr); +process.exit(plan.exitCode ?? 0); +`; + +interface Plan { + readonly output?: string | Uint8Array; + readonly exitCode?: number; + readonly stderr?: string; + readonly sleepMs?: number; + readonly grandchild?: boolean; + /** Writes the job's own process id, so a test can find a job a crashed server left behind. */ + readonly pidFile?: boolean; + /** Commits to the project repository mid-pass, moving its base ref. */ + readonly moveRef?: boolean; +} + +const utf8 = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); + +interface Calls { + readonly launches: Array; + readonly sends: Array; + /** The mode limit each send ran under, which the orchestrator re-checks inside the thread lock. */ + readonly sendLimits: Array; + readonly interrupts: Array; +} + +/** What every worker's thread looks like right now; a test edits it as a user would. */ +interface WorkerShell { + current: + | "unreadable" + | { + projectId: string; + runtimeMode: string; + interactionMode: string; + modelSelection: unknown; + status: string; + worktreePath: string | null; + pendingRuntimeRequest: { kind: string } | null; + } + | null; +} + +/** + * A fixture job directory plus the service under test. Thread launch and thread management are + * the boundary here; `CommandJobs.integration.test.ts` drives the real ones. + */ +const setup = Effect.fn("setup")(function* (options: { + readonly launch?: Record; + readonly timeoutMs?: number; + readonly args?: ReadonlyArray; + readonly acceptedCommands?: ReadonlySet; + readonly receiptReadFailure?: { current: boolean; readonly afterLaunch?: boolean }; + readonly failLaunchOnce?: "accepted" | "unaccepted"; + readonly baseRefMissing?: boolean; + readonly events?: Queue.Queue; + readonly processes?: Layer.Layer; + readonly taskIds?: ReadonlyArray; + readonly shell?: WorkerShell; + readonly records?: { + current: { runs: ReadonlyArray; subagents: ReadonlyArray }; + }; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "command-jobs-" }); + const stateDir = path.join(root, "userdata"); + const fixtureDir = path.join(root, "fixture"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.makeDirectory(fixtureDir, { recursive: true }); + const script = path.join(fixtureDir, "job.cjs"); + const planPath = path.join(fixtureDir, "plan.json"); + const logPath = path.join(fixtureDir, "runs.ndjson"); + const pidPath = path.join(fixtureDir, "grandchild.pid"); + const jobPidPath = path.join(fixtureDir, "job.pid"); + yield* fs.writeFileString(script, JOB_SCRIPT); + // The project repository the job's base ref resolves in. + const repo = path.join(root, "repo"); + yield* fs.makeDirectory(repo, { recursive: true }); + yield* git(repo, "init", "-q", "-b", "main"); + yield* git(repo, "commit", "--allow-empty", "-q", "-m", "base"); + const baseCommit = yield* git(repo, "rev-parse", "HEAD"); + yield* fs.writeFileString( + path.join(stateDir, CommandJobs.COMMAND_JOBS_CONFIG_FILE), + JSON.stringify({ + jobs: { + tick: { + projectId, + taskIds: options.taskIds ?? [task.id], + executable: process.execPath, + args: [script, planPath, ...(options.args ?? [])], + cwd: fixtureDir, + timeoutMs: options.timeoutMs ?? 30_000, + ...(options.launch === undefined ? {} : { launch: options.launch }), + }, + }, + }), + ); + const writePlan = (plan: Plan) => + fs.writeFileString( + planPath, + JSON.stringify({ + ...plan, + log: logPath, + ...(plan.grandchild === true ? { grandchild: pidPath } : {}), + ...(plan.pidFile === true ? { pidFile: jobPidPath } : {}), + ...(plan.moveRef === true ? { moveRef: repo } : {}), + ...(plan.output === undefined + ? {} + : { + output: Buffer.from( + typeof plan.output === "string" + ? new TextEncoder().encode(plan.output) + : plan.output, + ).toString("base64"), + }), + }), + ); + const runs = fs.readFileString(logPath).pipe( + Effect.map((text) => + text + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + argv: Array; + runId: string; + inputBytes: number; + workers: Array; + previousRejected: number; + baseCommit: string | null; + }, + ), + ), + Effect.orElseSucceed(() => []), + ); + const jobFile = (name: string) => path.join(stateDir, "command-jobs", "tick", name); + const readJson = (name: string) => + fs.readFileString(jobFile(name)).pipe(Effect.map((text) => JSON.parse(text))); + + const calls: Calls = { launches: [], sends: [], sendLimits: [], interrupts: [] }; + const acceptedCommands = new Set(options.acceptedCommands); + const layer = CommandJobs.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerConfig.ServerConfig, { stateDir } as never), + options.processes ?? ProcessRunner.layer, + NodeCrypto.layer, + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed( + id === projectId ? Option.some({ workspaceRoot: repo } as never) : Option.none(), + ), + }), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + checkWorktreeBase: () => + options.baseRefMissing === true + ? Effect.fail( + new ThreadLaunchService.ThreadLaunchBaseRefError({ + projectId, + projectTitle: "Factory", + workspaceRoot: "/repo", + baseRef: "main", + }), + ) + : Effect.void, + // Like the real service, a replayed command id resolves to the thread it created. + launch: (input) => + Effect.gen(function* () { + calls.launches.push(input); + if (options.failLaunchOnce !== undefined && calls.launches.length === 1) { + if (options.failLaunchOnce === "accepted") acceptedCommands.add(input.commandId); + return yield* new ThreadLaunchService.ThreadLaunchError({ + operation: "dispatch-message", + commandId: input.commandId, + projectId: input.projectId, + cause: new Error("Initial message dispatch failed."), + }); + } + return { + threadId: ThreadId.make(`thread:${input.commandId}`), + resumed: false, + } as ThreadLaunchService.ThreadLaunchResult; + }), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => + options.shell?.current === "unreadable" + ? Effect.fail(new Error("projection unavailable") as never) + : Effect.sync(() => (options.shell?.current ?? null) as never), + getThreadRecords: () => + Effect.sync(() => (options.records?.current ?? { runs: [], subagents: [] }) as never), + hasReceipt: (commandId) => + options.receiptReadFailure?.current === true && + (options.receiptReadFailure.afterLaunch !== true || calls.launches.length > 0) + ? Effect.fail(new CommandReceiptStore.CommandReceiptStoreReadError({ commandId })) + : Effect.succeed(acceptedCommands.has(commandId)), + sendToThread: (input) => + Effect.gen(function* () { + calls.sends.push(input); + calls.sendLimits.push(yield* DispatchModeLimit.DispatchModeLimit); + return {} as ThreadManagementService.ThreadManagementSendResult; + }), + interruptThread: (input) => + Effect.sync(() => { + calls.interrupts.push(input); + return { type: "no_active_run" } as const; + }), + streamDomainEvents: + options.events === undefined ? Stream.never : Stream.fromQueue(options.events), + }), + ), + ), + Layer.provide(NodeServices.layer), + ); + return { + fs, + path, + stateDir, + fixtureDir, + repo, + baseCommit, + pidPath, + jobPidPath, + writePlan, + runs, + jobFile, + readJson, + calls, + jobs: Context.get(yield* Layer.build(layer), CommandJobs.CommandJobs), + }; +}); + +/** Runs git for fixture repositories, returning trimmed stdout. */ +const git = (cwd: string, ...args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("git", ["-c", "user.name=t", "-c", "user.email=t@localhost", ...args], { + cwd, + stderr: "ignore", + }), + ); + const out = yield* child.stdout.pipe(Stream.decodeText(), Stream.mkString); + yield* child.exitCode; + return out.trim(); + }).pipe(Effect.scoped, Effect.orDie); + +const runJob = (jobs: CommandJobs.CommandJobs["Service"], invoker: Partial = {}) => + jobs.run({ task: { ...task, ...invoker }, jobName: "tick" }).pipe(Effect.flip, Effect.option); + +const ceiling = { + baseRef: "main", + branchPrefix: "factory/", + models: { builder, reviewer: { instanceId: "claudeAgent", model: "claude-opus-5-5" } }, + runtimeMode: "auto", +}; + +const launchRequest = (workId: string, extra: Record = {}) => ({ + type: "launch", + workId, + title: `Build ${workId}`, + prompt: `Implement ${workId}.`, + branch: `factory/${workId}`, + model: "builder", + ...extra, +}); + +it.live("an idle pass runs the configured executable directly and starts nothing", () => + Effect.gen(function* () { + const fixture = yield* setup({ args: ["a & echo injected", "%PATH%", '"quoted"'] }); + yield* fixture.writePlan({}); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(failure._tag === "None"); + const [run] = yield* fixture.runs; + // cmd.exe would have expanded %PATH% and split on &; a direct spawn hands them over as is. + assert.deepEqual(run?.argv, ["a & echo injected", "%PATH%", '"quoted"']); + assert.match(run?.runId ?? "", /^[0-9]+-1-[0-9a-z]+$/); + assert.lengthOf(fixture.calls.launches, 0); + const status = yield* fixture.readJson("status.json"); + assert.equal(status.status, "succeeded"); + assert.equal(status.exitCode, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("snapshots and requests larger than 32KB travel through files, with or without a BOM", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + const receipts = Array.from({ length: 400 }, (_, index) => ({ + workId: `existing-${index}`, + taskId: task.id, + threadId: `thread:existing-${index}`, + request: launchRequest(`existing-${index}`), + recordedAt: "2026-10-22T00:00:00.000Z", + })); + yield* fixture.fs.makeDirectory(fixture.path.dirname(fixture.jobFile("receipts.json")), { + recursive: true, + }); + yield* fixture.fs.writeFileString( + fixture.jobFile("receipts.json"), + JSON.stringify({ receipts }), + ); + const prompt = "x".repeat(40_000); + const body = utf8({ requests: [launchRequest("big", { prompt })] }); + yield* fixture.writePlan({ output: new Uint8Array([0xef, 0xbb, 0xbf, ...body]) }); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(failure._tag === "None", JSON.stringify(failure)); + const [run] = yield* fixture.runs; + assert.isAbove(run?.inputBytes ?? 0, 32 * 1024); + assert.lengthOf(run?.workers ?? [], 400); + assert.isAbove(body.length, 32 * 1024); + assert.lengthOf(fixture.calls.launches, 1); + assert.equal(fixture.calls.launches[0]?.initialMessage?.text, prompt); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("UTF-16 output blocks the job instead of being guessed at", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + const text = JSON.stringify({ requests: [launchRequest("one")] }); + const utf16 = new Uint8Array([0xff, 0xfe, ...new Uint8Array(Buffer.from(text, "utf16le"))]); + yield* fixture.writePlan({ output: utf16 }); + const failure = yield* runJob(fixture.jobs); + assert.match( + failure._tag === "Some" ? failure.value.message : "", + /^Blocked: output-[0-9a-z-]+.json must be UTF-8 text.$/, + ); + assert.lengthOf(fixture.calls.launches, 0); + assert.equal((yield* fixture.readJson("status.json")).status, "blocked"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("checks every request against the job's ceiling before acting on it", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: { ...ceiling, maxLaunchesPerPass: 2 } }); + yield* fixture.writePlan({ + output: JSON.stringify({ + requests: [ + launchRequest("ok", { runtimeMode: "auto-accept-edits", model: "reviewer" }), + launchRequest("unknown-model", { model: "frontier" }), + launchRequest("too-permissive", { runtimeMode: "full-access" }), + launchRequest("outside-prefix", { branch: "main" }), + launchRequest("bad id with spaces"), + { type: "send", workId: "never-launched", sendId: "s1", text: "Hello" }, + { type: "run_shell", command: "rm -rf /" }, + launchRequest("second"), + launchRequest("over-limit"), + ], + }), + }); + const failure = yield* runJob(fixture.jobs); + const message = failure._tag === "Some" ? failure.value.message : ""; + assert.match( + message, + /^Blocked: 7 of 9 request\(s\) not run\. unknown-model: Model "frontier"/, + ); + assert.deepEqual( + fixture.calls.launches.map((launch) => ({ + commandId: String(launch.commandId), + model: launch.modelSelection.model, + runtimeMode: launch.runtimeMode, + workspace: launch.workspaceStrategy, + projectId: launch.projectId, + })), + [ + { + commandId: "command-job:tick:ok", + model: "claude-opus-5-5", + runtimeMode: "auto-accept-edits", + workspace: { + type: "worktree", + baseRef: fixture.baseCommit, + branch: "factory/ok", + startFromOrigin: false, + }, + projectId, + }, + { + commandId: "command-job:tick:second", + model: "gpt-5.4", + runtimeMode: "auto", + workspace: { + type: "worktree", + baseRef: fixture.baseCommit, + branch: "factory/second", + startFromOrigin: false, + }, + projectId, + }, + ], + ); + assert.lengthOf(fixture.calls.sends, 0); + const status = yield* fixture.readJson("status.json"); + assert.deepEqual( + status.rejected.map((entry: { reason: string }) => entry.reason.slice(0, 24)), + [ + 'Model "frontier" is not a', + 'Runtime mode "full-access', + 'Branch must start with "f', + "Invalid request: the output does not match the request schema.", + "Only workers this job la", + "Invalid request: the output does not match the request schema.", + "Launch limit for this pa", + ].map((reason) => reason.slice(0, 24)), + ); + // The next pass tells the job what was refused last time. + yield* fixture.writePlan({}); + yield* runJob(fixture.jobs); + const [, second] = yield* fixture.runs; + assert.equal(second?.previousRejected, 7); + assert.deepEqual(second?.workers, ["ok", "second"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a missing base ref or a job without launch permission blocks the launch", () => + Effect.gen(function* () { + const missingBase = yield* setup({ launch: ceiling, baseRefMissing: true }); + yield* missingBase.writePlan({ output: JSON.stringify({ requests: [launchRequest("one")] }) }); + const baseFailure = yield* runJob(missingBase.jobs); + assert.match(baseFailure._tag === "Some" ? baseFailure.value.message : "", /Base ref "main"/); + assert.lengthOf(missingBase.calls.launches, 0); + + const readOnly = yield* setup({}); + yield* readOnly.writePlan({ output: JSON.stringify({ requests: [launchRequest("one")] }) }); + const readOnlyFailure = yield* runJob(readOnly.jobs); + assert.match( + readOnlyFailure._tag === "Some" ? readOnlyFailure.value.message : "", + /no launch permission/, + ); + assert.lengthOf(readOnly.calls.launches, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a restart finishes launches the orchestrator accepted and forgets ones it never saw", () => + Effect.gen(function* () { + const fixture = yield* setup({ + launch: ceiling, + acceptedCommands: new Set(["command-job:tick:accepted"]), + }); + const pending = (workId: string) => ({ + workId, + taskId: task.id, + threadId: null, + request: launchRequest(workId), + recordedAt: "2026-10-22T00:00:00.000Z", + }); + yield* fixture.fs.makeDirectory(fixture.path.dirname(fixture.jobFile("receipts.json")), { + recursive: true, + }); + yield* fixture.fs.writeFileString( + fixture.jobFile("receipts.json"), + JSON.stringify({ receipts: [pending("accepted"), pending("never-started")] }), + ); + // The job asks again for the accepted work in the same pass; it must not launch twice. + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("accepted")] }) }); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(failure._tag === "None", JSON.stringify(failure)); + assert.deepEqual( + fixture.calls.launches.map((launch) => launch.commandId), + ["command-job:tick:accepted"], + ); + const [run] = yield* fixture.runs; + assert.deepEqual(run?.workers, ["accepted"]); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.deepEqual( + receipts.map((receipt: { workId: string; threadId: string | null }) => [ + receipt.workId, + receipt.threadId, + ]), + [["accepted", "thread:command-job:tick:accepted"]], + ); + assert.deepEqual((yield* fixture.readJson("status.json")).unchanged, ["accepted"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +/** A worker exactly as the job launched it, inside the ceiling. */ +const launchedShell = () => ({ + projectId, + runtimeMode: "auto", + interactionMode: "default", + modelSelection: builder, + status: "completed", + worktreePath: null, + pendingRuntimeRequest: null, +}); + +it.live("messages and interrupts reach only this job's own workers", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling, shell: { current: launchedShell() } }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + yield* runJob(fixture.jobs); + yield* fixture.writePlan({ + output: JSON.stringify({ + requests: [ + { type: "send", workId: "w1", sendId: "review-1", text: "Address the review." }, + { type: "interrupt", workId: "w1" }, + { type: "interrupt", workId: "someone-else" }, + ], + }), + }); + const failure = yield* runJob(fixture.jobs); + assert.match(failure._tag === "Some" ? failure.value.message : "", /^Blocked: 1 of 3/); + assert.deepEqual( + fixture.calls.sends.map((send) => [send.threadId, send.commandId, send.text, send.mode]), + [ + [ + "thread:command-job:tick:w1", + "command-job:tick:w1:send:review-1", + "Address the review.", + "queue", + ], + ], + ); + assert.deepEqual( + fixture.calls.interrupts.map((interrupt) => interrupt.threadId), + ["thread:command-job:tick:w1"], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a timeout kills the job's whole process tree and is reported", () => + Effect.gen(function* () { + const fixture = yield* setup({ timeoutMs: 1_000 }); + yield* fixture.writePlan({ sleepMs: 60_000, grandchild: true }); + const failure = yield* runJob(fixture.jobs); + assert.equal(failure._tag === "Some" ? failure.value.message : null, "Timed out after 1000ms."); + const grandchild = Number(yield* fixture.fs.readFileString(fixture.pidPath)); + if (windowsHost) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const wait = yield* spawner.spawn( + ChildProcess.make( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Get-Process -Id ${grandchild} -ErrorAction SilentlyContinue | Wait-Process -Timeout 5 -ErrorAction SilentlyContinue`, + ], + { stdout: "ignore", stderr: "ignore" }, + ), + ); + yield* wait.exitCode; + } + const alive = (() => { + try { + process.kill(grandchild, 0); + return true; + } catch { + return false; + } + })(); + if (alive) process.kill(grandchild); + assert.isFalse(alive, `grandchild ${grandchild} survived the timeout`); + const status = yield* fixture.readJson("status.json"); + assert.equal(status.status, "failed"); + assert.isTrue(status.timedOut); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a failing exit is visible and its logs stay bounded", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + yield* fixture.writePlan({ + exitCode: 3, + stderr: `${"noise ".repeat(40_000)}final reason`, + output: JSON.stringify({ requests: [launchRequest("ignored")] }), + }); + const failure = yield* runJob(fixture.jobs); + const message = failure._tag === "Some" ? failure.value.message : ""; + assert.equal(message, "Exited with code 3."); + assert.notInclude(message, "final reason"); + assert.include((yield* fixture.readJson("status.json")).error, "noise"); + // Requests from a failed run are never acted on. + assert.lengthOf(fixture.calls.launches, 0); + const log = yield* fixture.fs.stat(fixture.jobFile("last-run.log")); + assert.isBelow(Number(log.size), 3 * 64 * 1024); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a failed process preserves its cause without copying its message", () => + Effect.gen(function* () { + const cause = new ProcessRunner.ProcessSpawnError({ + command: "sensitive-command-example", + argumentCount: 0, + cause: new Error("sensitive-underlying-detail"), + }); + const fixture = yield* setup({ + processes: Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => Effect.fail(cause), + }), + }); + const failure = yield* runJob(fixture.jobs); + assert.equal(failure._tag, "Some"); + if (failure._tag === "None") return; + assert.strictEqual(failure.value.cause, cause); + assert.equal(failure.value.operation, "run"); + assert.equal(failure.value.job, "tick"); + assert.notInclude(failure.value.message, "sensitive-"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +/** Stands in for the process so a test can hold a pass open. */ +const gatedProcesses = Effect.gen(function* () { + const started = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const count = yield* Ref.make(0); + const layer = Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Ref.updateAndGet(count, (n) => n + 1).pipe( + Effect.tap((n) => Queue.offer(started, n)), + Effect.andThen(Deferred.await(release)), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + Effect.as({ + stdout: "", + stderr: "", + code: 0 as never, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + ), + }); + return { started, release, interrupted, count, layer }; +}); + +it.live("triggers during a pass coalesce into one follow-up pass", () => + Effect.gen(function* () { + const gate = yield* gatedProcesses; + const fixture = yield* setup({ processes: gate.layer, taskIds: [task.id, "task:other"] }); + const first = yield* fixture.jobs.run({ task, jobName: "tick" }).pipe(Effect.forkChild); + assert.equal(yield* Queue.take(gate.started), 1); + // A second task on the same job, a worker completion and a manual run all land mid-pass. + yield* fixture.jobs.run({ + task: { ...task, id: ScheduledTaskId.make("task:other") }, + jobName: "tick", + }); + assert.isTrue(yield* fixture.jobs.requestPass(task, "tick")); + assert.isTrue(yield* fixture.jobs.requestPass(task, "tick")); + // A task the job is not bound to neither coalesces nor runs. + const unbound = { ...task, id: ScheduledTaskId.make("task:not-bound") }; + assert.isFalse(yield* fixture.jobs.requestPass(unbound, "tick")); + assert.isTrue((yield* runJob(fixture.jobs, unbound))._tag === "Some"); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(first); + assert.equal(yield* Ref.get(gate.count), 2); + // Idle again: nothing is running to coalesce into. + assert.isFalse(yield* fixture.jobs.requestPass(task, "tick")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("stop ends a running pass and interrupts its process", () => + Effect.gen(function* () { + const gate = yield* gatedProcesses; + const fixture = yield* setup({ processes: gate.layer }); + const running = yield* runJob(fixture.jobs).pipe(Effect.forkChild); + yield* Queue.take(gate.started); + yield* fixture.jobs.requestPass(task, "tick"); + yield* fixture.jobs.stop(task, "tick"); + const failure = yield* Fiber.join(running); + assert.equal(failure._tag === "Some" ? failure.value.message : null, "Stopped."); + yield* Deferred.await(gate.interrupted); + // A stop is not overridden by a pending follow-up. + assert.equal(yield* Ref.get(gate.count), 1); + assert.equal((yield* fixture.readJson("status.json")).status, "failed"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a failed pass still runs its coalesced follow-up", () => + Effect.gen(function* () { + const gate = yield* gatedProcesses; + const processes = Layer.effect( + ProcessRunner.ProcessRunner, + Effect.map(ProcessRunner.ProcessRunner, (runner) => ({ + run: (input: ProcessRunner.ProcessRunInput) => + runner.run(input).pipe( + Effect.flatMap((result) => + Ref.get(gate.count).pipe( + Effect.map((count) => ({ + ...result, + code: ChildProcessSpawner.ExitCode(count === 1 ? 1 : 0), + })), + ), + ), + ), + })), + ).pipe(Layer.provide(gate.layer)); + const fixture = yield* setup({ processes }); + const running = yield* runJob(fixture.jobs).pipe(Effect.forkChild); + yield* Queue.take(gate.started); + assert.isTrue(yield* fixture.jobs.requestPass(task, "tick")); + yield* Deferred.succeed(gate.release, undefined); + const failure = yield* Fiber.join(running); + assert.equal(yield* Ref.get(gate.count), 2); + assert.equal(failure._tag, "None"); + assert.equal((yield* fixture.readJson("status.json")).status, "succeeded"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live.each(["accepted", "unaccepted"] as const)( + "a new %s launch failure retains only accepted intent", + (acceptance) => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling, failLaunchOnce: acceptance }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + const failure = yield* runJob(fixture.jobs); + assert.equal(failure._tag, "Some"); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.lengthOf(receipts, acceptance === "accepted" ? 1 : 0); + if (acceptance === "accepted") { + assert.isNull(receipts[0].threadId); + assert.match(receipts[0].recoveryError, /launch failed/); + yield* fixture.writePlan({}); + yield* runJob(fixture.jobs); + const recovered = yield* fixture.readJson("receipts.json"); + assert.equal(recovered.receipts[0].threadId, "thread:command-job:tick:w1"); + assert.notProperty(recovered.receipts[0], "recoveryError"); + assert.lengthOf(fixture.calls.launches, 2); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("an unreadable receipt retains recovery intent without replaying it", () => + Effect.gen(function* () { + const receiptReadFailure = { current: true }; + const fixture = yield* setup({ + launch: ceiling, + acceptedCommands: new Set(["command-job:tick:w1"]), + receiptReadFailure, + }); + yield* writeReceipts(fixture, [ + { + workId: "w1", + taskId: task.id, + threadId: null, + request: launchRequest("w1"), + recordedAt: "2026-10-22T00:00:00.000Z", + }, + ]); + yield* fixture.writePlan({}); + const failure = yield* runJob(fixture.jobs); + assert.equal(failure._tag, "Some"); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.lengthOf(receipts, 1); + assert.isNull(receipts[0].threadId); + assert.include(receipts[0].recoveryError, "read failed"); + assert.lengthOf(fixture.calls.launches, 0); + receiptReadFailure.current = false; + yield* runJob(fixture.jobs); + assert.equal( + (yield* fixture.readJson("receipts.json")).receipts[0].threadId, + "thread:command-job:tick:w1", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("an unreadable receipt after launch failure retains intent and a recovery error", () => + Effect.gen(function* () { + const fixture = yield* setup({ + launch: ceiling, + failLaunchOnce: "accepted", + receiptReadFailure: { current: true, afterLaunch: true }, + }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + assert.equal((yield* runJob(fixture.jobs))._tag, "Some"); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.lengthOf(receipts, 1); + assert.include(receipts[0].recoveryError, "read failed"); + assert.lengthOf(fixture.calls.launches, 1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("an unreadable receipt prevents a fresh launch without local intent", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling, receiptReadFailure: { current: true } }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + const failure = yield* runJob(fixture.jobs); + assert.equal(failure._tag === "Some" ? failure.value.operation : null, "read"); + assert.lengthOf(fixture.calls.launches, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("receipt read failures retain their cause and allow later worker requests", () => + Effect.gen(function* () { + const receiptReadFailure = { current: false }; + const fixture = yield* setup({ + launch: ceiling, + receiptReadFailure, + shell: { current: launchedShell() }, + }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + yield* runJob(fixture.jobs); + receiptReadFailure.current = true; + yield* fixture.writePlan({ + output: JSON.stringify({ + requests: [ + launchRequest("w2"), + launchRequest("w3"), + { type: "send", workId: "w1", sendId: "after-read-failure", text: "Continue." }, + { type: "interrupt", workId: "w1" }, + ], + }), + }); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(Option.isSome(failure)); + if (Option.isNone(failure)) return; + assert.equal(failure.value.operation, "read"); + const cause = failure.value.cause; + assert.isTrue(isReceiptReadError(cause)); + if (isReceiptReadError(cause)) { + assert.equal(cause.commandId, "command-job:tick:w2"); + } + assert.lengthOf(fixture.calls.launches, 1); + assert.lengthOf(fixture.calls.sends, 1); + assert.lengthOf(fixture.calls.interrupts, 1); + const status = yield* fixture.readJson("status.json"); + assert.deepEqual( + status.failed.map((item: { workId: string }) => item.workId), + ["w2", "w3"], + ); + assert.equal(status.status, "blocked"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a worker finishing wakes its idle job's task", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const fixture = yield* setup({ launch: ceiling, events }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + yield* runJob(fixture.jobs); + const wakeup = yield* fixture.jobs.wakeups.pipe(Stream.runHead, Effect.forkChild); + const runUpdated = (threadId: string, status: string) => + ({ + type: "run.updated", + threadId, + payload: { status }, + }) as unknown as OrchestrationV2DomainEvent; + yield* Queue.offer(events, runUpdated("thread:unrelated", "completed")); + yield* Queue.offer(events, runUpdated("thread:command-job:tick:w1", "running")); + yield* Queue.offer(events, runUpdated("thread:command-job:tick:w1", "completed")); + const woken = yield* Fiber.join(wakeup); + assert.deepEqual(woken._tag === "Some" ? woken.value : null, task.id); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it("references a job only by an exact prompt prefix", () => { + assert.equal(CommandJobs.commandJobName("command-job:tick"), "tick"); + assert.equal(CommandJobs.commandJobName(" command-job: tick \n"), "tick"); + assert.isNull(CommandJobs.commandJobName("Run command-job:tick")); + assert.isNull(CommandJobs.commandJobName("Audit the repo.")); +}); + +it.live("only a bound task in the job's project with an adequate ceiling can run it", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + for (const invoker of [ + { projectId: ProjectId.make("project:unrelated") }, + { runtimeMode: "approval-required" as const }, + { id: ScheduledTaskId.make("task:not-bound") }, + ]) { + const failure = yield* runJob(fixture.jobs, invoker); + assert.match( + failure._tag === "Some" ? failure.value.message : "", + /may not run command job "tick"/, + JSON.stringify(invoker), + ); + } + // Refused before the command ran or anything launched. + assert.lengthOf(yield* fixture.runs, 0); + assert.lengthOf(fixture.calls.launches, 0); + // A refused task cannot add a pass to a running one either. + assert.isFalse( + yield* fixture.jobs.requestPass( + { ...task, id: ScheduledTaskId.make("task:not-bound") }, + "tick", + ), + ); + + const allowed = yield* runJob(fixture.jobs, { runtimeMode: "auto" }); + assert.isTrue(allowed._tag === "None", JSON.stringify(allowed)); + assert.lengthOf(fixture.calls.launches, 1); + assert.equal(fixture.calls.launches[0]?.projectId, projectId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a pass that writes no output acts on nothing, even after one that did", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("fresh")] }) }); + yield* runJob(fixture.jobs); + yield* fixture.writePlan({}); + const quiet = yield* runJob(fixture.jobs); + assert.isTrue(quiet._tag === "None", JSON.stringify(quiet)); + assert.deepEqual( + fixture.calls.launches.map((launch) => String(launch.commandId)), + ["command-job:tick:fresh"], + ); + assert.deepEqual((yield* fixture.readJson("status.json")).unchanged, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Holding a file open without delete sharing is how Windows makes a removal fail. +it.live.runIf(windowsHost)( + "output left behind by an earlier pass is never acted on, even when it cannot be removed", + () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* fixture.fs.makeDirectory(fixture.path.dirname(fixture.jobFile("x")), { + recursive: true, + }); + const stale = fixture.jobFile("output.json"); + yield* fixture.fs.writeFileString( + stale, + JSON.stringify({ requests: [launchRequest("stale")] }), + ); + const holder = yield* spawner.spawn( + ChildProcess.make( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$f = [IO.File]::Open('${stale.replaceAll("'", "''")}', 'Open', 'Read', 'Read'); 'held'; Start-Sleep -Seconds 120`, + ], + { stderr: "ignore" }, + ), + ); + const held = yield* holder.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runHead, + ); + assert.equal(Option.getOrNull(held), "held"); + const removal = yield* Effect.result(fixture.fs.remove(stale)); + assert.isTrue(removal._tag === "Failure", "the held file should not be removable"); + + yield* fixture.writePlan({}); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(failure._tag === "None", JSON.stringify(failure)); + assert.lengthOf(fixture.calls.launches, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** Resolves once `file` exists, woken by directory events rather than polling. */ +const awaitFile = (fs: FileSystem.FileSystem, dir: string, file: string) => + Stream.concat(Stream.make(undefined), fs.watch(dir)).pipe( + Stream.mapEffect(() => fs.exists(file)), + Stream.filter((exists) => exists), + Stream.runHead, + ); + +// The proof is platform behaviour: Node puts every child it starts on Windows in a job object that +// the system closes, killing the children, when the parent dies however it dies. A POSIX child +// runs in its own process group and outlives a crashed server; see CommandJobs.ts. +it.live.runIf(windowsHost)( + "a hard server crash ends its running job, so the restarted server's pass never overlaps it", + () => + Effect.gen(function* () { + const fixture = yield* setup({}); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const harness = yield* fixture.path.fromFileUrl( + new URL("../provider/testFixtures/command-job-crash-harness.mjs", import.meta.url), + ); + yield* fixture.writePlan({ sleepMs: 120_000, pidFile: true }); + const server = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + ["--experimental-strip-types", harness, fixture.stateDir, JSON.stringify(task)], + { + stdout: "ignore", + stderr: "ignore", + }, + ), + ); + yield* awaitFile(fixture.fs, fixture.fixtureDir, fixture.jobPidPath); + const job = Number(yield* fixture.fs.readFileString(fixture.jobPidPath)); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (isAlive(job)) process.kill(job); + }), + ); + assert.isTrue(isAlive(job), "the job should be mid-pass"); + // TerminateProcess: the server runs none of its own shutdown code. + process.kill(server.pid); + yield* server.exitCode.pipe(Effect.ignore); + assert.isFalse(isAlive(job), `job ${job} outlived the crashed server`); + + yield* fixture.writePlan({}); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(failure._tag === "None", JSON.stringify(failure)); + assert.lengthOf(yield* fixture.runs, 2); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a message reaches a worker only while it is still inside the job's ceiling", () => + Effect.gen(function* () { + const shell: WorkerShell = { current: launchedShell() }; + const fixture = yield* setup({ launch: ceiling, shell }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + yield* runJob(fixture.jobs); + const send = (sendId: string) => + fixture + .writePlan({ + output: JSON.stringify({ + requests: [{ type: "send", workId: "w1", sendId, text: "Next step." }], + }), + }) + .pipe(Effect.andThen(runJob(fixture.jobs))); + const refusal = (failure: Option.Option) => + failure._tag === "Some" ? failure.value.message : "sent"; + + assert.equal(refusal(yield* send("unchanged")), "sent"); + // The orchestrator re-checks the worker's modes against this under the thread's lock. + assert.deepEqual(fixture.calls.sendLimits, [ + { runtimeMode: "auto", interactionMode: "default" }, + ]); + // Another model the job is configured to use is still inside the ceiling. + shell.current = { ...launchedShell(), modelSelection: ceiling.models.reviewer }; + assert.equal(refusal(yield* send("reviewer")), "sent"); + + // A user later raised the worker's mode, moved it to another model, or another project. + shell.current = { ...launchedShell(), runtimeMode: "full-access" }; + assert.match(refusal(yield* send("raised")), /runs in full-access mode, above the job's auto/); + shell.current = { + ...launchedShell(), + modelSelection: { ...builder, options: [{ id: "fastMode", value: true }] }, + }; + assert.match(refusal(yield* send("other-options")), /uses a model this job is not configured/); + shell.current = { ...launchedShell(), projectId: "project:elsewhere" }; + assert.match(refusal(yield* send("moved")), /is no longer in project/); + shell.current = null; + assert.match(refusal(yield* send("deleted")), /no longer exists/); + assert.deepEqual( + fixture.calls.sends.map((sent) => String(sent.commandId).split(":").at(-1)), + ["unchanged", "reviewer"], + ); + + // Interrupting only ever stops work, so the job may still halt a worker a user widened. + shell.current = { ...launchedShell(), runtimeMode: "full-access" }; + yield* fixture.writePlan({ + output: JSON.stringify({ requests: [{ type: "interrupt", workId: "w1" }] }), + }); + assert.equal(refusal(yield* runJob(fixture.jobs)), "sent"); + assert.lengthOf(fixture.calls.interrupts, 1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("only a task bound to the job can stop its running pass", () => + Effect.gen(function* () { + const gate = yield* gatedProcesses; + const fixture = yield* setup({ processes: gate.layer, taskIds: [task.id, "task:shared"] }); + const running = yield* runJob(fixture.jobs).pipe(Effect.forkChild); + yield* Queue.take(gate.started); + yield* fixture.jobs.stop({ ...task, id: ScheduledTaskId.make("task:not-bound") }, "tick"); + yield* fixture.jobs.stop( + { ...task, id: ScheduledTaskId.make("task:shared"), projectId: ProjectId.make("project:x") }, + "tick", + ); + assert.isTrue(yield* fixture.jobs.requestPass(task, "tick"), "the pass should still run"); + // Bound tasks share the job, so any of them may stop it. + yield* fixture.jobs.stop({ ...task, id: ScheduledTaskId.make("task:shared") }, "tick"); + const failure = yield* Fiber.join(running); + assert.equal(failure._tag === "Some" ? failure.value.message : null, "Stopped."); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const reviewerSelection = { instanceId: "claudeAgent", model: "claude-opus-5-5" }; +const PROMPT_W1_SHA256 = "3bb89f4b3a5950e1882fb0967850410c8408bc5c2e16e23fbb23ac81a6f14700"; + +const writeReceipts = ( + fixture: Effect.Success>, + receipts: ReadonlyArray, +) => + fixture.fs + .makeDirectory(fixture.path.dirname(fixture.jobFile("receipts.json")), { recursive: true }) + .pipe( + Effect.andThen( + fixture.fs.writeFileString(fixture.jobFile("receipts.json"), JSON.stringify({ receipts })), + ), + ); + +it.live( + "every launch in a pass starts from the commit its base ref named when the pass began", + () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + // The ref moves while the job runs, before its launch request reaches the server. + yield* fixture.writePlan({ + moveRef: true, + output: JSON.stringify({ requests: [launchRequest("w1")] }), + }); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(failure._tag === "None", JSON.stringify(failure)); + const moved = yield* git(fixture.repo, "rev-parse", "HEAD"); + assert.notEqual(moved, fixture.baseCommit); + + const [run] = yield* fixture.runs; + assert.equal(run?.baseCommit, fixture.baseCommit, "the job is shown the pass's base"); + assert.deepEqual(fixture.calls.launches[0]?.workspaceStrategy, { + type: "worktree", + baseRef: fixture.baseCommit, + branch: "factory/w1", + startFromOrigin: false, + }); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.deepEqual(receipts[0].launch, { + resolvedBaseCommit: fixture.baseCommit, + promptSha256: PROMPT_W1_SHA256, + effective: { + providerInstanceId: "codex", + modelSelection: builder, + runtimeMode: "auto", + interactionMode: "default", + }, + worktreePath: null, + }); + + // The next pass shows the moved ref as its base, and the worker's receipt as launched. + yield* fixture.writePlan({}); + yield* runJob(fixture.jobs); + const input = yield* fixture.readJson("input.json"); + assert.equal(input.scope.baseCommit, moved); + assert.deepEqual(input.workers[0].receipt, { + prompt: "Implement w1.", + promptSha256: PROMPT_W1_SHA256, + branch: "factory/w1", + resolvedBaseCommit: fixture.baseCommit, + worktreePath: null, + effective: receipts[0].launch.effective, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a launch accepted before a crash is finished from what was pinned, not today's ref", () => + Effect.gen(function* () { + const fixture = yield* setup({ + launch: ceiling, + acceptedCommands: new Set(["command-job:tick:w1"]), + }); + const pinned = { + resolvedBaseCommit: fixture.baseCommit, + promptSha256: PROMPT_W1_SHA256, + effective: { + providerInstanceId: "claudeAgent", + modelSelection: reviewerSelection, + runtimeMode: "auto-accept-edits", + interactionMode: "plan", + }, + worktreePath: null, + }; + yield* writeReceipts(fixture, [ + { + workId: "w1", + taskId: task.id, + threadId: null, + request: launchRequest("w1"), + recordedAt: "2026-10-22T00:00:00.000Z", + launch: pinned, + }, + ]); + // Since the crash the ref moved; the request alone would now mean another base and model. + yield* git(fixture.repo, "commit", "--allow-empty", "-q", "-m", "moved"); + yield* fixture.writePlan({}); + const failure = yield* runJob(fixture.jobs); + assert.isTrue(failure._tag === "None", JSON.stringify(failure)); + const replay = fixture.calls.launches[0]; + assert.equal(String(replay?.commandId), "command-job:tick:w1"); + assert.deepEqual(replay?.workspaceStrategy, { + type: "worktree", + baseRef: fixture.baseCommit, + branch: "factory/w1", + startFromOrigin: false, + }); + assert.deepEqual(replay?.modelSelection, reviewerSelection); + assert.equal(replay?.runtimeMode, "auto-accept-edits"); + assert.equal(replay?.interactionMode, "plan"); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.equal(receipts[0].threadId, "thread:command-job:tick:w1"); + assert.deepEqual(receipts[0].launch, pinned); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("an accepted launch that cannot be finished is kept with its error, never dropped", () => + Effect.gen(function* () { + // The job's launch permission was removed after the orchestrator accepted the launch. + const fixture = yield* setup({ acceptedCommands: new Set(["command-job:tick:w1"]) }); + yield* writeReceipts(fixture, [ + { + workId: "w1", + taskId: task.id, + threadId: null, + request: launchRequest("w1"), + recordedAt: "2026-10-22T00:00:00.000Z", + }, + ]); + yield* fixture.writePlan({}); + const failure = yield* runJob(fixture.jobs); + assert.match( + failure._tag === "Some" ? failure.value.message : "", + /^Blocked: .*no longer has launch permission/, + ); + assert.lengthOf(fixture.calls.launches, 0); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.equal(receipts[0].threadId, null); + assert.match(receipts[0].recoveryError, /no longer has launch permission/); + const input = yield* fixture.readJson("input.json"); + assert.equal(input.workers[0].state, "launching"); + assert.match(input.workers[0].lastError, /no longer has launch permission/); + assert.isNull(input.workers[0].terminal); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const at = (iso: string) => DateTime.makeUnsafe(iso); +const runRecord = (ordinal: number, status: string, model: unknown = builder) => ({ + id: `run:${ordinal}`, + threadId: "thread:command-job:tick:w1", + ordinal, + providerInstanceId: (model as { instanceId: string }).instanceId, + modelSelection: model, + status, + queuePosition: status === "queued" ? 1 : null, + queueHeld: false, + requestedAt: at("2026-10-22T10:00:00.000Z"), + startedAt: status === "queued" ? null : at("2026-10-22T10:00:01.000Z"), + completedAt: status === "completed" ? at("2026-10-22T10:05:00.000Z") : null, +}); + +it.live("each worker's run history, liveness and terminal state are reported as recorded", () => + Effect.gen(function* () { + const shell: WorkerShell = { current: { ...launchedShell(), worktreePath: "C:/wt/w1" } }; + const records: { + current: { runs: ReadonlyArray; subagents: ReadonlyArray }; + } = { + current: { + runs: [runRecord(2, "queued", reviewerSelection), runRecord(1, "completed")], + subagents: [{ status: "running" }], + }, + }; + const fixture = yield* setup({ launch: ceiling, shell, records }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + yield* runJob(fixture.jobs); + const nextWorker = fixture.writePlan({}).pipe( + Effect.andThen(runJob(fixture.jobs)), + Effect.andThen(fixture.readJson("input.json")), + Effect.map((input) => input.workers[0]), + ); + + let worker = yield* nextWorker; + assert.deepEqual( + worker.runs.map((run: { ordinal: number; status: string; modelSelection: unknown }) => [ + run.ordinal, + run.status, + run.modelSelection, + ]), + [ + [1, "completed", builder], + [2, "queued", reviewerSelection], + ], + ); + assert.deepEqual(worker.runs[0], { + id: "run:1", + ordinal: 1, + status: "completed", + providerInstanceId: "codex", + modelSelection: builder, + requestedAt: "2026-10-22T10:00:00.000Z", + startedAt: "2026-10-22T10:00:01.000Z", + completedAt: "2026-10-22T10:05:00.000Z", + queuePosition: null, + queueHeld: false, + restartContinuationOfRunId: null, + delegatedTaskId: null, + }); + // No mode is reported per run: T3 does not persist one. + assert.notProperty(worker.runs[0], "runtimeMode"); + assert.isTrue(worker.runsComplete); + assert.equal(worker.runCount, 2); + assert.equal(worker.liveSubagents, 1); + assert.isFalse(worker.terminal, "a queued run and a live child are not terminal"); + assert.equal(worker.receipt.worktreePath, "C:/wt/w1", "recorded once the workspace exists"); + + records.current = { + runs: [runRecord(1, "completed"), runRecord(2, "completed")], + subagents: [{ status: "completed" }], + }; + worker = yield* nextWorker; + assert.isTrue(worker.terminal); + + records.current = { + runs: Array.from({ length: 101 }, (_, index) => runRecord(index + 1, "completed")), + subagents: [], + }; + worker = yield* nextWorker; + assert.isFalse(worker.runsComplete); + assert.equal(worker.runCount, 101); + assert.lengthOf(worker.runs, 100); + assert.equal(worker.runs[0].ordinal, 2, "the newest 100 runs are kept"); + + // The recorded worktree never changes, and a thread moved out of the project reads as unknown. + shell.current = { + ...launchedShell(), + worktreePath: "C:/elsewhere", + projectId: "project:other", + }; + worker = yield* nextWorker; + assert.equal(worker.state, "outside-project"); + assert.deepEqual(worker.runs, []); + assert.isNull(worker.runCount); + assert.isNull(worker.terminal); + assert.isNull(worker.current); + assert.isNull(worker.worktreePath); + assert.equal(worker.receipt.worktreePath, "C:/wt/w1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live( + "a receipt from before launch facts existed reports them as unknown, never back-filled", + () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling, shell: { current: launchedShell() } }); + yield* writeReceipts(fixture, [ + { + workId: "old", + taskId: task.id, + threadId: "thread:old", + request: launchRequest("old"), + recordedAt: "2026-10-21T00:00:00.000Z", + }, + ]); + yield* fixture.writePlan({}); + yield* runJob(fixture.jobs); + const input = yield* fixture.readJson("input.json"); + assert.isNull(input.workers[0].receipt); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.notProperty(receipts[0], "launch"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a malformed launch is still reported against the work it names", () => + Effect.gen(function* () { + const fixture = yield* setup({ launch: ceiling }); + yield* fixture.writePlan({ + output: JSON.stringify({ requests: [{ type: "launch", workId: "w-bad" }] }), + }); + yield* runJob(fixture.jobs); + const status = yield* fixture.readJson("status.json"); + assert.equal(status.rejected[0].workId, "w-bad"); + assert.match(status.rejected[0].reason, /^Invalid request/); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("a worker whose thread cannot be read is unknown, not missing and never terminal", () => + Effect.gen(function* () { + const shell: WorkerShell = { current: launchedShell() }; + const fixture = yield* setup({ launch: ceiling, shell }); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + yield* runJob(fixture.jobs); + shell.current = "unreadable"; + yield* fixture.writePlan({}); + yield* runJob(fixture.jobs); + const worker = (yield* fixture.readJson("input.json")).workers[0]; + assert.equal(worker.state, "unreadable"); + assert.isNull(worker.terminal); + assert.isNull(worker.runCount); + assert.isFalse(worker.runsComplete); + // Gone from T3 entirely reads differently. + shell.current = null; + yield* runJob(fixture.jobs); + const gone = (yield* fixture.readJson("input.json")).workers[0]; + assert.equal(gone.state, "missing"); + assert.isNull(gone.terminal); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live( + "work launched before whose receipt is gone fails instead of getting new launch facts", + () => + Effect.gen(function* () { + // The orchestrator accepted this launch long ago; the job's receipt was pruned or lost. + const fixture = yield* setup({ + launch: ceiling, + acceptedCommands: new Set(["command-job:tick:w1"]), + }); + yield* git(fixture.repo, "commit", "--allow-empty", "-q", "-m", "moved since"); + yield* fixture.writePlan({ output: JSON.stringify({ requests: [launchRequest("w1")] }) }); + const failure = yield* runJob(fixture.jobs); + assert.match( + failure._tag === "Some" ? failure.value.message : "", + /^Blocked: .*w1: This work was launched before/, + ); + assert.lengthOf(fixture.calls.launches, 0); + const status = yield* fixture.readJson("status.json"); + assert.deepEqual(status.rejected, [], "a rejection would let a consumer release the claim"); + assert.equal(status.failed[0].workId, "w1"); + const { receipts } = yield* fixture.readJson("receipts.json"); + assert.deepEqual(receipts, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/commandJobs/CommandJobs.ts b/apps/server/src/commandJobs/CommandJobs.ts new file mode 100644 index 000000000000..5477c855274c --- /dev/null +++ b/apps/server/src/commandJobs/CommandJobs.ts @@ -0,0 +1,1202 @@ +/** + * Model-free scheduled command jobs. + * + * A scheduled task whose prompt is exactly `command-job:` runs the command configured as + * `` in `/command-jobs.json` instead of starting a provider turn. That host file is + * the trust anchor: clients can only reference a job, never choose its executable, arguments or + * launch ceiling. The job names the tasks allowed to run it and the one project it works in, and a + * task whose own runtime mode is below the launch ceiling cannot run it, so knowing a job's name + * grants nothing. + * + * Each pass writes `input.json` (the workers this job launched and their current state) and runs + * the executable directly with `T3_JOB_INPUT` / `T3_JOB_OUTPUT` pointing at files, so snapshots and + * requests of any size never travel through argv or stdout. The output path is new for every pass, + * so nothing an earlier pass left behind is ever read. Requests are untrusted: each is validated + * against the job's launch ceiling before anything runs. + * + * Launch command ids derive from the job and work id, so the orchestration command receipt is the + * durable dedupe across retries and crashes. The job's own `receipts.json` records intent before a + * launch and the thread after it, which lets a restarted pass finish an accepted launch or drop one + * that never started. This is durable dedupe and recovery, not exactly-once execution of the + * command itself. + * + * A server crash mid-pass cannot leave the job running into the next pass on Windows, where Node + * starts children in a job object that the system kills with the server. On POSIX the job runs in + * its own process group and survives a hard crash; no restart reconciliation exists for that yet. + */ +import { + CommandId, + MessageId, + ModelSelection, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + ProviderInteractionMode, + ProjectId, + RuntimeMode, + type ScheduledTask, + ScheduledTaskId, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import { + DispatchModeLimit, + exceededDispatchModeLimit, +} from "../orchestration-v2/DispatchModeLimit.ts"; +import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ProjectService from "../project/ProjectService.ts"; + +const COMMAND_JOB_PROMPT_PREFIX = "command-job:"; +export const COMMAND_JOBS_CONFIG_FILE = "command-jobs.json"; +const LOG_LIMIT_BYTES = 64 * 1024; +const OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; +const RECEIPT_RETENTION = 1000; +const DEFAULT_MAX_LAUNCHES_PER_PASS = 3; +/** Triggers that arrive during a pass coalesce into one follow-up; this bounds the chain. */ +const MAX_PASSES_PER_RUN = 3; +/** Run history per worker in each snapshot; older runs are flagged rather than sent. */ +const MAX_RUNS_PER_WORKER = 100; +const TERMINAL_RUN_STATUSES = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", + "rolled_back", +]); +const TERMINAL_SUBAGENT_STATUSES = new Set(["completed", "failed", "cancelled", "interrupted"]); +/** Interaction mode never limits a job, so ceilings compare runtime mode alone. */ +const exceedsRuntimeMode = (ceiling: RuntimeMode, mode: RuntimeMode) => + exceededDispatchModeLimit( + { runtimeMode: ceiling, interactionMode: "default" }, + { runtimeMode: mode, interactionMode: "default" }, + ) !== undefined; +/** Run states after which the job should look at its workers again. */ +const WAKE_RUN_STATUSES = new Set([ + "waiting", + "completed", + "failed", + "cancelled", + "interrupted", + "rolled_back", +]); + +/** The job name a task references, or null for an ordinary agent task. */ +export function commandJobName(prompt: string): string | null { + const trimmed = prompt.trim(); + return trimmed.startsWith(COMMAND_JOB_PROMPT_PREFIX) + ? trimmed.slice(COMMAND_JOB_PROMPT_PREFIX.length).trim() + : null; +} + +const JobName = /^[A-Za-z0-9_-]{1,64}$/; +const Identifier = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9._:-]{1,128}$/)); +const Branch = Schema.String.check( + Schema.isPattern(/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]{1,200}(?()("CommandJobError", { + operation: Schema.Literals([ + "validate", + "hash", + "read", + "write", + "decode", + "prepare", + "launch", + "run", + "dispatch", + ]), + job: Schema.optional(Schema.String), + path: Schema.optional(Schema.String), + reason: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + if (this.reason !== undefined) return this.reason; + const subject = this.job === undefined ? "Command job" : `Command job "${this.job}"`; + const location = this.path === undefined ? "" : ` at ${this.path}`; + return `${subject} ${this.operation} failed${location}.`; + } +} + +const fail = ( + reason: string, + context: Omit[0], "reason"> = { + operation: "validate", + }, +) => Effect.fail(new CommandJobError({ ...context, reason })); + +const isoOrNull = (value: DateTime.DateTime | null | undefined) => + value == null ? null : DateTime.formatIso(value); + +const launchCommandId = (job: string, workId: string) => + CommandId.make(`command-job:${job}:${workId}`); + +// Strips a UTF-8 BOM and throws on anything else that is not UTF-8, including the UTF-16 that +// Windows PowerShell 5.1 writes by default. +const utf8 = new TextDecoder("utf-8", { fatal: true }); + +function decodeTextFile(bytes: Uint8Array): string | null { + try { + return utf8.decode(bytes); + } catch { + return null; + } +} + +interface PassOutcome { + readonly launched: Array; + readonly unchanged: Array; + readonly rejected: Array<{ + readonly index: number; + readonly workId?: string; + readonly reason: string; + }>; + readonly failed: Array<{ + readonly index: number; + readonly workId?: string; + readonly reason: string; + }>; +} + +export class CommandJobs extends Context.Service< + CommandJobs, + { + readonly run: (input: { + readonly task: ScheduledTask; + readonly jobName: string; + }) => Effect.Effect; + /** + * True when a pass is running and will run once more for this task; false when the caller + * must start one, including when the task may not run the job, so `run` reports why. + */ + readonly requestPass: (task: ScheduledTask, jobName: string) => Effect.Effect; + /** + * Ends a running pass, killing its process tree. Tasks bound to one job share it, so any of + * them may stop it; a task the job is not bound to cannot. + */ + readonly stop: ( + task: Pick, + jobName: string, + ) => Effect.Effect; + /** Tasks whose workers changed state while their job was idle. */ + readonly wakeups: Stream.Stream; + } +>()("t3/commandJobs/CommandJobs") {} + +export const layer = Layer.effect( + CommandJobs, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { stateDir } = yield* ServerConfig.ServerConfig; + const processes = yield* ProcessRunner.ProcessRunner; + const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; + const threads = yield* ThreadManagementService.ThreadManagementService; + const projects = yield* ProjectService.ProjectService; + const crypto = yield* Crypto.Crypto; + const sha256 = (text: string) => + crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe( + Effect.map((bytes) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + Effect.mapError((cause) => new CommandJobError({ operation: "hash", cause })), + ); + const configPath = path.join(stateDir, COMMAND_JOBS_CONFIG_FILE); + const jobDir = (job: string) => path.join(stateDir, "command-jobs", job); + + // Presence means a pass is running; `dirty` asks it for one more. + const passes = yield* Ref.make< + ReadonlyMap< + string, + { + readonly dirty: boolean; + readonly stop: Deferred.Deferred; + /** The binding the pass was authorized under, so stopping it needs no file read. */ + readonly config: JobConfig; + } + > + >(new Map()); + const workers = yield* Ref.make< + ReadonlyMap + >(new Map()); + const wakeQueue = yield* Queue.unbounded(); + + const writeJson = (filePath: string, value: unknown) => + writeFileStringAtomically({ filePath, contents: `${JSON.stringify(value, null, 2)}\n` }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => new CommandJobError({ operation: "write", path: filePath, cause }), + ), + ); + + const readText = Effect.fn("CommandJobs.readText")(function* ( + filePath: string, + limit?: number, + ) { + if (!(yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)))) return null; + if (limit !== undefined) { + const info = yield* fs + .stat(filePath) + .pipe( + Effect.mapError( + (cause) => new CommandJobError({ operation: "read", path: filePath, cause }), + ), + ); + if (Number(info.size) > limit) + return yield* fail(`${path.basename(filePath)} is larger than ${limit} bytes.`); + } + const bytes = yield* fs + .readFile(filePath) + .pipe( + Effect.mapError( + (cause) => new CommandJobError({ operation: "read", path: filePath, cause }), + ), + ); + const text = decodeTextFile(bytes); + if (text === null) return yield* fail(`${path.basename(filePath)} must be UTF-8 text.`); + return text; + }); + + /** The job's host configuration, once `task` is shown to be allowed to run it. */ + const loadJob = Effect.fn("CommandJobs.loadJob")(function* (task: ScheduledTask, job: string) { + if (!JobName.test(job)) return yield* fail(`Invalid command job name "${job}".`); + const text = yield* readText(configPath); + if (text === null) return yield* fail(`No ${COMMAND_JOBS_CONFIG_FILE} on this server.`); + const config = yield* decodeConfig(text).pipe( + Effect.mapError( + (cause) => + new CommandJobError({ + operation: "decode", + path: configPath, + job, + cause, + }), + ), + ); + const found = config.jobs[job]; + if (found === undefined) return yield* fail(`Command job "${job}" is not configured.`); + const refuse = (reason: string) => + fail(`Task "${task.id}" may not run command job "${job}": ${reason}`); + if (!found.taskIds.includes(task.id)) return yield* refuse("it is not in the job's taskIds."); + if (task.projectId !== found.projectId) + return yield* refuse(`it is in project "${task.projectId}", not "${found.projectId}".`); + if ( + found.launch !== undefined && + exceedsRuntimeMode(task.runtimeMode, found.launch.runtimeMode) + ) + return yield* refuse( + `its runtime mode "${task.runtimeMode}" is below the job's launch ceiling "${found.launch.runtimeMode}".`, + ); + if (!path.isAbsolute(found.executable) || !path.isAbsolute(found.cwd)) + return yield* fail("A command job's executable and cwd must be absolute paths."); + const extension = path.extname(found.executable).toLowerCase(); + // Node can only start .cmd/.bat through cmd.exe, which re-parses the arguments. + if (extension === ".cmd" || extension === ".bat") + return yield* fail("Command jobs run executables directly; .cmd and .bat need a shell."); + return found; + }); + + const readReceipts = Effect.fnUntraced(function* (job: string) { + const text = yield* readText(path.join(jobDir(job), "receipts.json")); + if (text === null) return [] as ReadonlyArray; + return (yield* decodeReceipts(text).pipe( + Effect.mapError( + (cause) => + new CommandJobError({ + operation: "decode", + job, + path: path.join(jobDir(job), "receipts.json"), + cause, + }), + ), + )).receipts; + }); + + const indexWorkers = (job: string, receipts: ReadonlyArray) => + Ref.update(workers, (current) => { + const next = new Map(current); + for (const receipt of receipts) + if (receipt.threadId !== null) + next.set(receipt.threadId, { job, taskId: receipt.taskId }); + return next; + }); + + /** The commit `baseRef` names in the project's repository now, or null if it names none. */ + const resolveBaseCommit = Effect.fn("CommandJobs.resolveBaseCommit")(function* ( + projectId: ProjectId, + baseRef: string, + ) { + const project = yield* projects.getById(projectId).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + if (project === null) return null; + const resolved = yield* processes + .run({ + command: "git", + args: ["rev-parse", "--verify", "--quiet", "--end-of-options", `${baseRef}^{commit}`], + cwd: project.workspaceRoot, + timeout: 30_000, + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.orElseSucceed(() => null)); + const commit = resolved?.code === 0 ? resolved.stdout.trim() : ""; + return /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(commit) ? commit : null; + }); + + /** Records the worker's worktree path once its workspace is ready; nothing else changes. */ + const observeWorktree = Effect.fn("CommandJobs.observeWorktree")(function* ( + threadId: ThreadId, + record: LaunchRecord, + projectId: ProjectId, + ) { + if (record.worktreePath !== null) return record; + const shell = yield* threads.getThreadShell(threadId).pipe(Effect.orElseSucceed(() => null)); + return shell === null || shell.projectId !== projectId || shell.worktreePath === null + ? record + : { ...record, worktreePath: shell.worktreePath }; + }); + + const workerSnapshot = Effect.fn("CommandJobs.workerSnapshot")(function* ( + receipt: Receipt, + projectId: ProjectId, + ) { + // A failed read is not a missing thread: both leave the worker's state unknown, but only + // a thread T3 no longer has is reported missing. + const read = + receipt.threadId === null + ? null + : yield* Effect.result(threads.getThreadShell(receipt.threadId)); + const unreadable = read !== null && read._tag === "Failure"; + const found = read === null || read._tag === "Failure" ? null : read.success; + // A thread moved to another project is no longer this job's to read. + const outside = found !== null && found.projectId !== projectId; + const shell = outside ? null : found; + const records = + receipt.threadId === null || shell === null + ? null + : yield* threads + .getThreadRecords(receipt.threadId, ["runs", "subagents"]) + .pipe(Effect.orElseSucceed(() => null)); + const runs = records === null ? [] : [...records.runs].sort((a, b) => a.ordinal - b.ordinal); + const liveSubagents = + records === null + ? null + : records.subagents.filter((child) => !TERMINAL_SUBAGENT_STATUSES.has(child.status)) + .length; + return { + workId: receipt.workId, + threadId: receipt.threadId, + title: receipt.request.title, + model: receipt.request.model, + recordedAt: receipt.recordedAt, + receipt: + receipt.launch === undefined + ? null + : { + prompt: receipt.request.prompt, + promptSha256: receipt.launch.promptSha256, + branch: receipt.request.branch, + resolvedBaseCommit: receipt.launch.resolvedBaseCommit, + worktreePath: receipt.launch.worktreePath, + effective: receipt.launch.effective, + }, + state: + receipt.threadId === null + ? "launching" + : outside + ? "outside-project" + : unreadable + ? "unreadable" + : shell === null + ? "missing" + : shell.status, + pendingRequest: shell?.pendingRuntimeRequest?.kind ?? null, + lastError: receipt.recoveryError ?? shell?.lastError ?? null, + branch: shell?.branch ?? receipt.request.branch, + worktreePath: shell?.worktreePath ?? null, + latestRunId: shell?.latestRunId ?? null, + current: + shell === null + ? null + : { + modelSelection: shell.modelSelection, + runtimeMode: shell.runtimeMode, + interactionMode: shell.interactionMode, + }, + // Per-run runtime and interaction modes are not persisted, so they are not reported. + runs: runs.slice(-MAX_RUNS_PER_WORKER).map((run) => ({ + id: run.id, + ordinal: run.ordinal, + status: run.status, + providerInstanceId: run.providerInstanceId, + modelSelection: run.modelSelection, + requestedAt: isoOrNull(run.requestedAt), + startedAt: isoOrNull(run.startedAt), + completedAt: isoOrNull(run.completedAt), + queuePosition: run.queuePosition ?? null, + queueHeld: run.queueHeld ?? false, + restartContinuationOfRunId: run.restartContinuationOfRunId ?? null, + delegatedTaskId: run.delegatedTaskId ?? null, + })), + runsComplete: records !== null && runs.length <= MAX_RUNS_PER_WORKER, + runCount: records === null ? null : runs.length, + liveSubagents, + // Null when the thread cannot be read: unknown, never terminal. + terminal: + records === null || shell === null + ? null + : runs.length > 0 && + runs.every((run) => TERMINAL_RUN_STATUSES.has(run.status)) && + shell.pendingRuntimeRequest === null && + liveSubagents === 0, + }; + }); + + const runPass = Effect.fn("CommandJobs.runPass")(function* (input: { + readonly task: ScheduledTask; + readonly job: string; + readonly config: JobConfig; + readonly stop: Deferred.Deferred; + readonly pass: number; + }) { + const { task, job, config, stop } = input; + const dir = jobDir(job); + yield* fs + .makeDirectory(dir, { recursive: true }) + .pipe( + Effect.mapError( + (cause) => new CommandJobError({ operation: "prepare", job, path: dir, cause }), + ), + ); + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const started = yield* DateTime.now; + const startedAt = DateTime.formatIso(started); + const nonce = (yield* Random.nextIntBetween(0, 2 ** 31 - 1)).toString(36); + const runId = `${DateTime.toEpochMillis(started)}-${input.pass}-${nonce}`; + const inputPath = path.join(dir, "input.json"); + // Never reused, so a file an earlier pass left (or an orphan of it still writes) is never read. + const outputPath = path.join(dir, `output-${runId}.json`); + const receiptsPath = path.join(dir, "receipts.json"); + const receipts = new Map( + (yield* readReceipts(job)).map((receipt) => [receipt.workId, receipt]), + ); + const saveReceipts = () => { + const kept = [...receipts.values()]; + const excess = kept.length - RECEIPT_RETENTION; + return writeJson(receiptsPath, { + receipts: excess > 0 ? kept.filter((r, i) => r.threadId === null || i >= excess) : kept, + }); + }; + const outcome: PassOutcome = { launched: [], unchanged: [], rejected: [], failed: [] }; + // Every launch in the pass starts from the commit the ref names now, which is also what the + // job is shown, so a ref that moves while the job runs cannot change what it launches. + const baseCommit = + config.launch === undefined + ? null + : yield* resolveBaseCommit(config.projectId, config.launch.baseRef); + + const validateLaunch = Effect.fn("CommandJobs.validateLaunch")(function* ( + request: LaunchRequest, + ceiling: LaunchCeiling, + base: string | null, + ) { + if (base === null) + return yield* fail(`Base ref "${ceiling.baseRef}" does not resolve to a commit.`); + const modelSelection = ceiling.models[request.model]; + if (modelSelection === undefined) + return yield* fail(`Model "${request.model}" is not allowed.`); + const runtimeMode = request.runtimeMode ?? ceiling.runtimeMode; + if (exceedsRuntimeMode(ceiling.runtimeMode, runtimeMode)) + return yield* fail(`Runtime mode "${runtimeMode}" exceeds "${ceiling.runtimeMode}".`); + if (!request.branch.startsWith(ceiling.branchPrefix)) + return yield* fail(`Branch must start with "${ceiling.branchPrefix}".`); + const workspaceStrategy = { + type: "worktree" as const, + baseRef: base, + branch: request.branch, + startFromOrigin: false, + }; + yield* threadLaunch + .checkWorktreeBase({ projectId: config.projectId, workspaceStrategy }) + .pipe( + Effect.mapError( + (cause) => + new CommandJobError({ + operation: "validate", + job, + reason: `Base ref "${ceiling.baseRef}" is not in the project.`, + cause, + }), + ), + ); + return { + modelSelection, + runtimeMode, + interactionMode: request.interactionMode ?? ("default" as const), + workspaceStrategy, + }; + }); + + type LaunchSettings = Effect.Success>; + const pinnedLaunch = (request: LaunchRequest, record: LaunchRecord): LaunchSettings => ({ + modelSelection: record.effective.modelSelection, + runtimeMode: record.effective.runtimeMode, + interactionMode: record.effective.interactionMode, + workspaceStrategy: { + type: "worktree", + baseRef: record.resolvedBaseCommit, + branch: request.branch, + startFromOrigin: false, + }, + }); + + const launch = Effect.fn("CommandJobs.launch")(function* ( + request: LaunchRequest, + { modelSelection, runtimeMode, interactionMode, workspaceStrategy }: LaunchSettings, + ) { + const launched = yield* threadLaunch + .launch({ + commandId: launchCommandId(job, request.workId), + projectId: config.projectId, + title: request.title, + modelSelection, + runtimeMode, + interactionMode, + workspaceStrategy, + initialMessage: { + messageId: MessageId.make(`command-job-message:${job}:${request.workId}`), + scheduledTaskId: task.id, + text: request.prompt, + attachments: [], + }, + createdBy: "system", + creationSource: "server", + }) + .pipe( + Effect.mapError((cause) => new CommandJobError({ operation: "launch", job, cause })), + ); + return launched.threadId; + }); + + const readReceipt = (workId: string) => + threads + .hasReceipt(launchCommandId(job, workId)) + .pipe(Effect.mapError((cause) => new CommandJobError({ operation: "read", job, cause }))); + + // Launches whose intent was recorded but whose thread was not. Ones the orchestrator never + // saw are forgotten, so a runner that still wants the work asks again. Accepted ones are + // finished from what was pinned before dispatch (the replay returns the same thread); if + // that cannot happen, the intent stays with its error, since the work may exist and no + // consumer may release its claim. + for (const receipt of receipts.values()) { + if (receipt.threadId !== null) continue; + const accepted = yield* Effect.result(readReceipt(receipt.workId)); + if (accepted._tag === "Failure") { + receipts.set(receipt.workId, { ...receipt, recoveryError: accepted.failure.message }); + outcome.failed.push({ + index: -1, + workId: receipt.workId, + reason: accepted.failure.message, + }); + continue; + } + if (!accepted.success) { + receipts.delete(receipt.workId); + continue; + } + const ceiling = config.launch; + const resumed = yield* Effect.result( + ceiling === undefined + ? fail( + "This job no longer has launch permission, so its accepted launch was not finished.", + ) + : receipt.launch !== undefined + ? launch(receipt.request, pinnedLaunch(receipt.request, receipt.launch)) + : validateLaunch(receipt.request, ceiling, baseCommit).pipe( + Effect.flatMap((valid) => launch(receipt.request, valid)), + ), + ); + if (resumed._tag === "Success") { + const { recoveryError: _recovered, ...rest } = receipt; + receipts.set(receipt.workId, { ...rest, threadId: resumed.success }); + continue; + } + receipts.set(receipt.workId, { ...receipt, recoveryError: resumed.failure.message }); + outcome.failed.push({ index: -1, workId: receipt.workId, reason: resumed.failure.message }); + } + // A worker's workspace is prepared after its launch returns, so its path lands later. + for (const receipt of receipts.values()) { + if (receipt.threadId === null || receipt.launch === undefined) continue; + receipts.set(receipt.workId, { + ...receipt, + launch: yield* observeWorktree(receipt.threadId, receipt.launch, config.projectId), + }); + } + yield* saveReceipts(); + yield* indexWorkers(job, [...receipts.values()]); + + const previous = yield* readText(path.join(dir, "status.json")).pipe( + Effect.flatMap((text) => (text === null ? Effect.succeed(null) : decodeStatus(text))), + Effect.orElseSucceed(() => null), + ); + yield* writeJson(inputPath, { + version: 1, + job, + taskId: task.id, + runId, + startedAt, + scope: + config.launch === undefined + ? null + : { + projectId: config.projectId, + baseRef: config.launch.baseRef, + baseCommit, + branchPrefix: config.launch.branchPrefix, + runtimeMode: config.launch.runtimeMode, + models: config.launch.models, + maxLaunchesPerPass: + config.launch.maxLaunchesPerPass ?? DEFAULT_MAX_LAUNCHES_PER_PASS, + }, + workers: yield* Effect.forEach([...receipts.values()], (receipt) => + workerSnapshot(receipt, config.projectId), + ), + previousRejected: previous?.rejected ?? [], + }); + + const finished = yield* Effect.raceFirst( + processes + .run({ + command: config.executable, + args: config.args, + cwd: config.cwd, + timeout: config.timeoutMs, + timeoutBehavior: "timedOutResult", + env: { + T3_JOB_NAME: job, + T3_JOB_RUN_ID: runId, + T3_JOB_INPUT: inputPath, + T3_JOB_OUTPUT: outputPath, + }, + maxOutputBytes: LOG_LIMIT_BYTES, + outputMode: "truncate", + truncatedMarker: "\n[truncated]\n", + }) + .pipe( + Effect.map((result) => ({ _tag: "exited" as const, result })), + Effect.mapError( + (cause) => + new CommandJobError({ operation: "run", job, path: config.executable, cause }), + ), + ), + Deferred.await(stop).pipe(Effect.as({ _tag: "stopped" as const })), + ); + + const writeStatus = (status: string, error: string | null) => + nowIso.pipe( + Effect.flatMap((finishedAt) => + writeJson(path.join(dir, "status.json"), { + runId, + taskId: task.id, + startedAt, + finishedAt, + status, + error, + exitCode: finished._tag === "exited" ? finished.result.code : null, + timedOut: finished._tag === "exited" && finished.result.timedOut, + ...outcome, + }), + ), + ); + + if (finished._tag === "exited") { + const { stdout, stderr } = finished.result; + yield* fs + .writeFileString( + path.join(dir, "last-run.log"), + `run ${runId} exit ${String(finished.result.code)}\n--- stdout\n${stdout}\n--- stderr\n${stderr}\n`, + ) + .pipe(Effect.ignore); + } + const processError = + finished._tag === "stopped" + ? "Stopped." + : finished.result.timedOut + ? `Timed out after ${config.timeoutMs}ms.` + : finished.result.code !== 0 + ? `Exited with code ${String(finished.result.code)}: ${finished.result.stderr.trim().slice(-500)}` + : null; + if (processError !== null) { + yield* fs.remove(outputPath).pipe(Effect.ignore); + yield* writeStatus("failed", processError); + return yield* fail( + finished._tag === "exited" && !finished.result.timedOut + ? `Exited with code ${String(finished.result.code)}.` + : processError, + { operation: "run", job }, + ); + } + const outputText = yield* Effect.result(readText(outputPath, OUTPUT_LIMIT_BYTES)); + yield* fs.remove(outputPath).pipe(Effect.ignore); + const output = + outputText._tag === "Failure" + ? outputText + : outputText.success === null + ? null + : yield* Effect.result( + decodeOutput(outputText.success).pipe( + Effect.mapError( + (cause) => + new CommandJobError({ operation: "decode", job, path: outputPath, cause }), + ), + ), + ); + if (output !== null && output._tag === "Failure") { + yield* writeStatus("blocked", output.failure.message); + return yield* fail(`Blocked: ${output.failure.message}`, { + operation: "decode", + job, + path: outputPath, + cause: output.failure, + }); + } + + const requests = output?.success.requests ?? []; + let launchesLeft = config.launch?.maxLaunchesPerPass ?? DEFAULT_MAX_LAUNCHES_PER_PASS; + let dispatchFailure: CommandJobError | undefined; + let receiptFailure: CommandJobError | undefined; + for (const [index, raw] of requests.entries()) { + if (yield* Deferred.isDone(stop)) break; + const decoded = yield* Effect.result(decodeRequest(raw)); + if (decoded._tag === "Failure") { + // A malformed request still names its work, where it gives one, so a consumer can match it. + const workId = + typeof raw === "object" && + raw !== null && + "workId" in raw && + typeof raw.workId === "string" + ? raw.workId + : undefined; + outcome.rejected.push({ + index, + ...(workId === undefined ? {} : { workId }), + reason: "Invalid request: the output does not match the request schema.", + }); + continue; + } + const request: JobRequest = decoded.success; + const reject = (reason: string) => + outcome.rejected.push({ index, workId: request.workId, reason }); + const existing = receipts.get(request.workId); + if (request.type === "launch") { + const ceiling = config.launch; + if (ceiling === undefined) { + reject("This job has no launch permission."); + continue; + } + if (existing !== undefined) { + outcome.unchanged.push(request.workId); + continue; + } + // Accepted before, but its local receipt is gone (pruned or lost): a fresh intent would + // claim today's base, model and prompt for a thread launched with others. Its facts + // are unknown, so this fails, and the work stays claimed until someone reconciles it. + const launchedBefore = yield* Effect.result(readReceipt(request.workId)); + if (launchedBefore._tag === "Failure") { + receiptFailure ??= launchedBefore.failure; + outcome.failed.push({ + index, + workId: request.workId, + reason: launchedBefore.failure.message, + }); + continue; + } + if (launchedBefore.success) { + outcome.failed.push({ + index, + workId: request.workId, + reason: + "This work was launched before but has no local receipt, so its launch facts are unknown; reconcile it before launching again.", + }); + continue; + } + const valid = yield* Effect.result(validateLaunch(request, ceiling, baseCommit)); + if (valid._tag === "Failure") { + reject(valid.failure.message); + continue; + } + if (launchesLeft === 0) { + reject("Launch limit for this pass reached."); + continue; + } + launchesLeft -= 1; + const recordedAt = yield* nowIso; + const intent: LaunchRecord = { + resolvedBaseCommit: valid.success.workspaceStrategy.baseRef, + promptSha256: yield* sha256(request.prompt), + effective: { + providerInstanceId: valid.success.modelSelection.instanceId, + modelSelection: valid.success.modelSelection, + runtimeMode: valid.success.runtimeMode, + interactionMode: valid.success.interactionMode, + }, + worktreePath: null, + }; + const receipt = { + workId: request.workId, + taskId: task.id, + threadId: null, + request, + recordedAt, + launch: intent, + }; + receipts.set(request.workId, receipt); + yield* saveReceipts(); + const launched = yield* Effect.result(launch(request, valid.success)); + if (launched._tag === "Failure") { + const accepted = yield* Effect.result(readReceipt(request.workId)); + const error = accepted._tag === "Failure" ? accepted.failure : launched.failure; + if (accepted._tag === "Success" && !accepted.success) { + receipts.delete(request.workId); + } else { + receipts.set(request.workId, { ...receipt, recoveryError: error.message }); + } + yield* saveReceipts(); + outcome.failed.push({ + index, + workId: request.workId, + reason: error.message, + }); + continue; + } + receipts.set(request.workId, { + workId: request.workId, + taskId: task.id, + threadId: launched.success, + request, + recordedAt, + launch: yield* observeWorktree(launched.success, intent, config.projectId), + }); + yield* saveReceipts(); + yield* indexWorkers(job, [receipts.get(request.workId)!]); + outcome.launched.push(request.workId); + continue; + } + if (existing?.threadId == null || config.launch === undefined) { + reject("Only workers this job launched can be messaged or interrupted."); + continue; + } + const threadId = existing.threadId; + const projectId = config.projectId; + const limit = { + runtimeMode: config.launch.runtimeMode, + interactionMode: "default" as const, + }; + // A user may have widened the worker since the job launched it, and the config may have + // tightened; a message would start a turn under whatever the thread has now. Interrupts + // only stop work, so the job may still halt its own worker after a user widened it. + if (request.type === "send") { + const shell = yield* threads + .getThreadShell(threadId) + .pipe(Effect.orElseSucceed(() => null)); + const refusal = + shell === null + ? "Its thread no longer exists." + : shell.projectId !== projectId + ? `Its thread is no longer in project "${projectId}".` + : exceededDispatchModeLimit(limit, shell) !== undefined + ? `Its thread runs in ${shell.runtimeMode} mode, above the job's ${limit.runtimeMode} ceiling.` + : !Object.values(config.launch.models).some((model) => + modelSelectionsEqual(model, shell.modelSelection), + ) + ? "Its thread uses a model this job is not configured to launch." + : null; + if (refusal !== null) { + reject(refusal); + continue; + } + } + const result = yield* Effect.result( + request.type === "send" + ? threads + .sendToThread({ + projectId, + commandId: CommandId.make( + `command-job:${job}:${request.workId}:send:${request.sendId}`, + ), + threadId, + messageId: MessageId.make( + `command-job-message:${job}:${request.workId}:send:${request.sendId}`, + ), + scheduledTaskId: task.id, + text: request.text, + attachments: [], + mode: "queue", + createdBy: "system", + creationSource: "server", + }) + // Re-checked inside the thread's command lock, so a change after the read above + // is refused too. + .pipe(Effect.provideService(DispatchModeLimit, limit)) + : threads.interruptThread({ + projectId, + commandId: CommandId.make( + `command-job:${job}:${request.workId}:interrupt:${runId}`, + ), + threadId, + reason: "Interrupted by its command job.", + }), + ); + if (result._tag === "Failure") { + dispatchFailure = new CommandJobError({ + operation: "dispatch", + job, + cause: result.failure, + }); + outcome.failed.push({ index, workId: request.workId, reason: dispatchFailure.message }); + } + } + + const problems = [...outcome.rejected, ...outcome.failed]; + if (problems.length === 0) return yield* writeStatus("succeeded", null); + const first = problems[0]!; + const message = `Blocked: ${problems.length} of ${requests.length} request(s) not run. ${first.workId ?? `#${first.index}`}: ${first.reason}`; + yield* writeStatus("blocked", message); + if (receiptFailure !== undefined) return yield* Effect.fail(receiptFailure); + return yield* fail(message, { operation: "dispatch", job, cause: dispatchFailure }); + }); + + const run: CommandJobs["Service"]["run"] = Effect.fn("CommandJobs.run")(function* ({ + task, + jobName, + }) { + const config = yield* loadJob(task, jobName); + const stop = yield* Deferred.make(); + const claimed = yield* Ref.modify(passes, (current) => { + const running = current.get(jobName); + if (running !== undefined) + return [false, new Map(current).set(jobName, { ...running, dirty: true })] as const; + return [true, new Map(current).set(jobName, { dirty: false, stop, config })] as const; + }); + // Another task referencing the same job is mid-pass; it runs once more for this trigger. + if (!claimed) return; + yield* Effect.gen(function* () { + for (let pass = 1; ; pass += 1) { + const result = yield* Effect.result(runPass({ task, job: jobName, config, stop, pass })); + const again = yield* Ref.modify(passes, (current) => { + const state = current.get(jobName); + const next = new Map(current); + if ( + state?.dirty === true && + pass < MAX_PASSES_PER_RUN && + !Deferred.isDoneUnsafe(stop) + ) { + next.set(jobName, { ...state, dirty: false }); + return [true, next] as const; + } + next.delete(jobName); + return [false, next] as const; + }); + if (!again) { + if (result._tag === "Failure") return yield* Effect.fail(result.failure); + return; + } + } + }).pipe( + Effect.ensuring( + Ref.update(passes, (current) => { + if (current.get(jobName)?.stop !== stop) return current; + const next = new Map(current); + next.delete(jobName); + return next; + }), + ), + ); + }); + + const markDirty = (jobName: string) => + Ref.modify(passes, (current) => { + const running = current.get(jobName); + if (running === undefined) return [false, current] as const; + return [true, new Map(current).set(jobName, { ...running, dirty: true })] as const; + }); + + const requestPass: CommandJobs["Service"]["requestPass"] = (task, jobName) => + loadJob(task, jobName).pipe( + Effect.andThen(markDirty(jobName)), + Effect.orElseSucceed(() => false), + ); + + // Only the binding is checked: a bound task lowered below the launch ceiling can still be + // paused to stop the job it can no longer start. + const stop: CommandJobs["Service"]["stop"] = (task, jobName) => + Ref.get(passes).pipe( + Effect.flatMap((current) => { + const running = current.get(jobName); + return running === undefined || + !running.config.taskIds.includes(task.id) || + task.projectId !== running.config.projectId + ? Effect.void + : Deferred.succeed(running.stop, undefined); + }), + Effect.asVoid, + ); + + // Index existing workers so their completions wake the job after a restart too. + yield* readText(configPath).pipe( + Effect.flatMap((text) => (text === null ? Effect.succeed(null) : decodeConfig(text))), + Effect.flatMap((config) => + Effect.forEach(Object.keys(config?.jobs ?? {}), (job) => + readReceipts(job).pipe(Effect.flatMap((receipts) => indexWorkers(job, receipts))), + ), + ), + Effect.catch((cause) => Effect.logWarning("Could not index command job workers", { cause })), + ); + + yield* threads.streamDomainEvents.pipe( + Stream.runForEach((event) => + event.type === "run.updated" && WAKE_RUN_STATUSES.has(event.payload.status) + ? Ref.get(workers).pipe( + Effect.flatMap((current) => { + const owner = current.get(event.threadId); + if (owner === undefined) return Effect.void; + return markDirty(owner.job).pipe( + Effect.flatMap((coalesced) => + coalesced ? Effect.void : Queue.offer(wakeQueue, owner.taskId), + ), + ); + }), + ) + : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning("Command job worker watch stopped", { cause }), + ), + Effect.forkScoped, + ); + + return CommandJobs.of({ run, requestPass, stop, wakeups: Stream.fromQueue(wakeQueue) }); + }), +); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index bba4e816caca..feb8d0c7b5b0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1327,6 +1327,60 @@ describe("OrchestratorMcpService provider resolution", () => { ); }); + it.effect.each(["delegation", "creation"] as const)( + "reports receipt-read failures during %s as operation errors", + (operation) => + Effect.gen(function* () { + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([])), + hasReceipt: (commandId) => + Effect.fail(new CommandReceiptStore.CommandReceiptStoreReadError({ commandId })), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ServerSettings.layerTest(), + ); + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + if (operation === "delegation") { + const result = yield* service + .delegateTask(scope, { + task: "Summarize the diff.", + target: { providerInstanceId: codexInstanceId }, + mode: "async", + clientRequestId: "receipt-read-delegation", + }) + .pipe(Effect.flip); + assert.equal(result.code, "orchestration_error"); + } else { + const result = yield* service + .createThreads(scope, { + threads: [ + { + prompt: "Summarize the diff.", + target: { providerInstanceId: codexInstanceId }, + }, + ], + clientRequestId: "receipt-read-creation", + }) + .pipe(Effect.flip); + assert.equal(result.code, "orchestration_error"); + } + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect("rejects delegation to a provider without a registered adapter", () => Effect.gen(function* () { const forkOnlyInstanceId = ProviderInstanceId.make("forkOnly"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index edaa8777b422..4c8c14462229 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2164,7 +2164,9 @@ const make = Effect.gen(function* () { requestKey: key, operation: "delegate-task", }); - const isRetryOfAcceptedDelegation = yield* threadManagement.hasReceipt(commandId); + const isRetryOfAcceptedDelegation = yield* threadManagement + .hasReceipt(commandId) + .pipe(Effect.mapError(threadManagementFailure)); const target = yield* resolveTargetRechecking({ parent, target: input.target, @@ -2549,7 +2551,9 @@ const make = Effect.gen(function* () { requestKey: key, index, }); - const isRetryOfAcceptedCreation = yield* threadManagement.hasReceipt(commandId); + const isRetryOfAcceptedCreation = yield* threadManagement + .hasReceipt(commandId) + .pipe(Effect.mapError(threadManagementFailure)); const committedThread = isRetryOfAcceptedCreation ? yield* threadManagement .getThreadShell(threadId) diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 8d1f613180a3..7c54572f720f 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -29,6 +29,29 @@ const layerReceiptsTest = CommandReceiptStore.layer.pipe( Layer.provide(SqlitePersistence.layerMemory), ); +it.effect("preserves receipt-read failures instead of reporting receipt absence", () => { + const commandId = CommandId.make("command:receipt-read-failure"); + const failure = new CommandReceiptStore.CommandReceiptStoreReadError({ + commandId, + cause: new Error("Receipt storage unavailable."), + }); + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide(Layer.mock(Orchestrator.OrchestratorV2)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({ + getByCommandId: () => Effect.fail(failure), + }), + ), + ); + return Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + const result = yield* Effect.result(threads.hasReceipt(commandId)); + expect(result).toMatchObject({ _tag: "Failure", failure }); + }).pipe(Effect.provide(layerTest)); +}); + const layerDispatchChokepointTest = (settingsLayer: ReturnType) => (dispatched: Array) => ThreadManagementService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 543b3e2f89fe..6e20c10575fc 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -391,7 +391,9 @@ export interface ThreadManagementServiceShape { * replay the recorded result, never start anything new, so the filter would * just refuse a retry the project's settings no longer allow fresh. */ - readonly hasReceipt: (commandId: CommandId) => Effect.Effect; + readonly hasReceipt: ( + commandId: CommandId, + ) => Effect.Effect; readonly waitForThread: ( input: ThreadManagementWaitInput, ) => Effect.Effect; @@ -546,10 +548,7 @@ const make = Effect.gen(function* () { ); const hasReceipt: ThreadManagementServiceShape["hasReceipt"] = (commandId) => - receipts.getByCommandId(commandId).pipe( - Effect.orElseSucceed(() => Option.none()), - Effect.map(Option.isSome), - ); + receipts.getByCommandId(commandId).pipe(Effect.map(Option.isSome)); const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index ad4c7ecda878..7afea2299019 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -53,6 +53,8 @@ import * as ThreadLifecycleService from "./ThreadLifecycleService.ts"; import * as ThreadForkService from "./ThreadForkService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as CommandJobs from "../commandJobs/CommandJobs.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; export const layerEventInfrastructure = Layer.mergeAll( @@ -289,6 +291,16 @@ const layerScheduledTaskProvided = ScheduledTaskService.layer.pipe( layerThreadManagementProvided, layerManagedProjectFoldersProvided, layerSecretRequestsProvided, + CommandJobs.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerThreadLaunchProvided, + layerThreadManagementProvided, + layerProjectService, + ProcessRunner.layer, + ), + ), + ), ), ), ); diff --git a/apps/server/src/provider/testFixtures/command-job-crash-harness.mjs b/apps/server/src/provider/testFixtures/command-job-crash-harness.mjs new file mode 100644 index 000000000000..174659f6420c --- /dev/null +++ b/apps/server/src/provider/testFixtures/command-job-crash-harness.mjs @@ -0,0 +1,48 @@ +// Stands in for a T3 server that starts one command-job pass and is then killed without running +// any shutdown code. Used by CommandJobs.test.ts to leave a real orphaned job behind. +// Usage: node command-job-crash-harness.mjs +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import * as CommandJobs from "../../commandJobs/CommandJobs.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagementService from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ProcessRunner from "../../processRunner.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; + +const [stateDir, taskJson] = process.argv.slice(2); +const layer = CommandJobs.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerConfig.ServerConfig, { stateDir }), + ProcessRunner.layer, + NodeCrypto.layer, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + hasReceipt: () => Effect.succeed(false), + streamDomainEvents: Stream.never, + }), + ), + ), + Layer.provide(NodeServices.layer), +); + +Effect.runFork( + Effect.scoped( + Layer.build(layer).pipe( + Effect.flatMap((context) => + Context.get(context, CommandJobs.CommandJobs).run({ + task: JSON.parse(taskJson), + jobName: "tick", + }), + ), + ), + ), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 466af14e7f22..bfa6745811a6 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -8,6 +8,7 @@ import { ProviderInstanceId, ScheduledTaskError, ScheduledTaskId, + ScheduledTaskUpsertInput, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -19,6 +20,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; import * as Deferred from "effect/Deferred"; +import * as Context from "effect/Context"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -29,8 +31,135 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import * as CommandJobs from "../commandJobs/CommandJobs.ts"; const isScheduledTaskError = Schema.is(ScheduledTaskError); +const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); + +it.effect.each([ + { name: "editing a disabled task", enabled: false, nextEnabled: false, stop: false }, + { name: "editing an enabled task", enabled: true, nextEnabled: true, stop: false }, + { name: "enabling a task", enabled: false, nextEnabled: true, stop: false }, + { name: "disabling an enabled task", enabled: true, nextEnabled: false, stop: true }, + { + name: "changing a disabled task's job", + enabled: false, + nextEnabled: false, + prompt: "command-job:other", + stop: true, + }, + { + name: "changing a disabled task's project", + enabled: false, + nextEnabled: false, + projectId: "project:other", + stop: true, + }, +])("stops the old command job only when needed while $name", (scenario) => + Effect.gen(function* () { + const stopped = yield* Ref.make>([]); + const dependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.succeed(CommandJobs.CommandJobs, { + requestPass: () => Effect.succeed(false), + run: () => Effect.void, + stop: (task, job) => Ref.update(stopped, (calls) => [...calls, `${task.projectId}:${job}`]), + wakeups: Stream.never, + }), + ); + yield* Effect.gen(function* () { + const tasks = yield* ScheduledTaskService.ScheduledTaskService; + const input = yield* decodeUpsertInput({ + id: "task:edit-job", + title: "Tick", + prompt: "command-job:tick", + enabled: scenario.enabled, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: "project:test", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + yield* tasks.upsert(input); + assert.deepEqual(yield* Ref.get(stopped), []); + const update = yield* decodeUpsertInput({ + ...input, + requireExisting: true, + title: "Edited tick", + enabled: scenario.nextEnabled, + prompt: scenario.prompt ?? input.prompt, + projectId: scenario.projectId ?? input.projectId, + }); + yield* tasks.upsert(update); + assert.deepEqual(yield* Ref.get(stopped), scenario.stop ? ["project:test:tick"] : []); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("rechecks command-job coalescing before a busy task retry", () => + Effect.gen(function* () { + const requests = yield* Ref.make(0); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const context = yield* Layer.build( + ScheduledTaskService.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + SqlitePersistence.layerMemory, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.succeed(CommandJobs.CommandJobs, { + requestPass: () => + Ref.updateAndGet(requests, (count) => count + 1).pipe( + Effect.map((count) => count >= 3), + ), + run: () => + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))), + stop: () => Effect.void, + wakeups: Stream.never, + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ), + ); + const tasks = Context.get(context, ScheduledTaskService.ScheduledTaskService); + const sql = Context.get(context, SqlClient.SqlClient); + const id = ScheduledTaskId.make("task:retry-job"); + yield* insertRow( + sql, + { + id, + next: null, + enabled: 0, + status: "never", + prompt: "command-job:tick", + }, + "2026-09-09T12:00:00.000Z", + ); + yield* tasks.runNow({ id }); + yield* Deferred.await(entered); + yield* tasks.runNow({ id }); + yield* TestClock.adjust("1 second"); + assert.equal(yield* Ref.get(requests), 3); + yield* Deferred.succeed(release, undefined); + }).pipe(Effect.scoped), +); const insertRow = ( sql: SqlClient.SqlClient, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 374d6b38027b..57cb50c0f45d 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -2,6 +2,7 @@ import { CommandId, MessageId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type ProjectId, ScheduledTask, ScheduledTaskError, ScheduledTaskId, @@ -37,11 +38,13 @@ import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; +import * as CommandJobs from "../commandJobs/CommandJobs.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as Metrics from "../observability/Metrics.ts"; @@ -407,6 +410,8 @@ export const layer = Layer.effect( const secretRequests = yield* SecretRequests.SecretRequests; const scheduler = yield* Scheduler.Scheduler; const readWebhookOrigin = yield* ScheduledTaskWebhookOrigin; + const commandJobs = yield* Effect.serviceOption(CommandJobs.CommandJobs); + const commandJobScope = yield* Effect.scope; const webhookPermits = yield* Ref.make>( new Map(), ); @@ -827,47 +832,57 @@ export const layer = Layer.effect( // Effect.exit (not Effect.result) so defects and interruptions in the // dispatch are also captured and recorded as a failed run instead of // aborting before markCompleted. + const jobName = CommandJobs.commandJobName(active.prompt); + const runJob = ( + name: string, + ): Effect.Effect => + Option.isSome(commandJobs) + ? commandJobs.value.run({ task: active, jobName: name }).pipe(Effect.as(null)) + : Effect.fail(taskError("Command jobs are not available on this server.")); const result = - active.threadId === null - ? yield* Effect.exit( - threadLaunch - .launch({ - commandId, - projectId: active.projectId, - title: active.title, - modelSelection: active.modelSelection, - runtimeMode: active.runtimeMode, - interactionMode: active.interactionMode, - workspaceStrategy: active.workspaceStrategy, - initialMessage: { + // A command-job reference never falls through to a provider turn. + jobName !== null + ? yield* Effect.exit(runJob(jobName)) + : active.threadId === null + ? yield* Effect.exit( + threadLaunch + .launch({ + commandId, + projectId: active.projectId, + title: active.title, + modelSelection: active.modelSelection, + runtimeMode: active.runtimeMode, + interactionMode: active.interactionMode, + workspaceStrategy: active.workspaceStrategy, + initialMessage: { + messageId, + scheduledTaskId: active.id, + text: prompt, + attachments: [], + }, + createdBy: active.createdBy, + creationSource: active.creationSource, + }) + .pipe(Effect.map((launched) => launched.threadId)), + ) + : yield* Effect.exit( + // The stored selection only seeds fresh threads. A bound run + // follows the thread's current provider, so it passes none. + threadManagement + .sendToThread({ + projectId: active.projectId, + commandId, + threadId: ThreadId.make(active.threadId), messageId, scheduledTaskId: active.id, text: prompt, attachments: [], - }, - createdBy: active.createdBy, - creationSource: active.creationSource, - }) - .pipe(Effect.map((launched) => launched.threadId)), - ) - : yield* Effect.exit( - // The stored selection only seeds fresh threads. A bound run - // follows the thread's current provider, so it passes none. - threadManagement - .sendToThread({ - projectId: active.projectId, - commandId, - threadId: ThreadId.make(active.threadId), - messageId, - scheduledTaskId: active.id, - text: prompt, - attachments: [], - mode: "queue", - createdBy: active.createdBy, - creationSource: active.creationSource, - }) - .pipe(Effect.as(ThreadId.make(active.threadId))), - ); + mode: "queue", + createdBy: active.createdBy, + creationSource: active.creationSource, + }) + .pipe(Effect.as(ThreadId.make(active.threadId))), + ); const runSucceeded = result._tag === "Success"; if (webhook !== undefined && runSucceeded) yield* Ref.set(webhook.dispatched, true); @@ -971,7 +986,16 @@ export const layer = Layer.effect( Effect.suspend(() => isMissedFixedTimeRun(task.schedule, dueAt, now) ? rescheduleMissedRun(task, now) - : runTask(task, "scheduled"), + : CommandJobs.commandJobName(task.prompt) !== null + ? // A command can run for minutes; other due tasks must not wait on it. + runTask(task, "scheduled").pipe( + Effect.catch((cause) => + Effect.logWarning("Command job run failed", { taskId: task.id, cause }), + ), + Effect.forkIn(commandJobScope), + Effect.asVoid, + ) + : runTask(task, "scheduled"), ).pipe( Effect.catch((cause) => Effect.logWarning("Scheduled task run failed", { taskId: task.id, cause }), @@ -1037,6 +1061,51 @@ export const layer = Layer.effect( yield* scheduler.register("scheduled-tasks", runDueTasks()); + // Coalesces into a running pass, otherwise starts one in the background. + const triggerCommandJob = Effect.fn("ScheduledTaskService.triggerCommandJob")(function* ( + task: ScheduledTask, + jobName: string, + ) { + yield* Effect.gen(function* () { + if (Option.isSome(commandJobs) && (yield* commandJobs.value.requestPass(task, jobName))) + return; + yield* runTask(task, "manual"); + }).pipe( + // The previous pass may still be releasing the task. + Effect.retry({ times: 5, schedule: Schedule.spaced("1 second") }), + Effect.catch((cause) => + Effect.logWarning("Command job run failed", { taskId: task.id, cause }), + ), + Effect.forkIn(commandJobScope), + ); + }); + + const stopCommandJob = (task: Pick | null) => { + const jobName = task === null ? null : CommandJobs.commandJobName(task.prompt); + return task === null || jobName === null || Option.isNone(commandJobs) + ? Effect.void + : commandJobs.value.stop(task, jobName); + }; + + if (Option.isSome(commandJobs)) { + yield* commandJobs.value.wakeups.pipe( + Stream.runForEach((id) => + findTask(id).pipe( + Effect.flatMap((task) => { + const jobName = task === null ? null : CommandJobs.commandJobName(task.prompt); + return task === null || jobName === null || !task.enabled + ? Effect.void + : triggerCommandJob(task, jobName); + }), + Effect.catch((cause) => + Effect.logWarning("Could not wake command job", { taskId: id, cause }), + ), + ), + ), + Effect.forkIn(commandJobScope), + ); + } + const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( Effect.map((tasks) => ({ tasks })), @@ -1181,6 +1250,17 @@ export const layer = Layer.effect( runCount: existingTask?.runCount ?? 0, }; yield* saveTask(task, input.requireExisting === true, webhook); + // Pausing a command-job task by editing it, or pointing it elsewhere, must not leave its + // running pass with no row that can stop it. The old row is what the job was bound to. + const previousJob = + existingTask === null ? null : CommandJobs.commandJobName(existingTask.prompt); + if ( + previousJob !== null && + ((existingTask?.enabled === true && !input.enabled) || + CommandJobs.commandJobName(input.prompt) !== previousJob || + existingTask?.projectId !== input.projectId) + ) + yield* stopCommandJob(existingTask); yield* notifyChanged; return { task: (yield* findTask(id)) ?? task }; }); @@ -1209,6 +1289,7 @@ export const layer = Layer.effect( if (updated.length === 0) { return yield* taskError("Schedule task not found.", { taskId: input.id }); } + if (!input.enabled) yield* stopCommandJob(existing); yield* notifyChanged; return { task: { ...existing, enabled: input.enabled, nextRunAt: next, updatedAt: iso(now) }, @@ -1248,7 +1329,10 @@ export const layer = Layer.effect( ); const deleteTask: ScheduledTaskService["Service"]["delete"] = (input) => - deleteRow(input.id).pipe( + findTask(input.id).pipe( + Effect.flatMap(stopCommandJob), + Effect.ignore, + Effect.andThen(deleteRow(input.id)), Effect.andThen(forgetWebhookTask(input.id)), Effect.andThen(notifyChanged), Effect.as({ id: input.id }), @@ -1266,8 +1350,9 @@ export const layer = Layer.effect( DELETE FROM scheduled_task_webhook_deliveries WHERE task_id IN (SELECT task_id FROM scheduled_tasks WHERE thread_id = ${threadId}) `; - return yield* sql<{ task_id: ScheduledTaskId }>` - DELETE FROM scheduled_tasks WHERE thread_id = ${threadId} RETURNING task_id + return yield* sql<{ task_id: ScheduledTaskId; project_id: ProjectId; prompt: string }>` + DELETE FROM scheduled_tasks WHERE thread_id = ${threadId} + RETURNING task_id, project_id, prompt `; }), ) @@ -1279,6 +1364,13 @@ export const layer = Layer.effect( yield* Effect.forEach(removed, ({ task_id }) => forgetWebhookTask(task_id), { discard: true, }); + // A task created from a thread is bound to it, so deleting the thread can remove a + // command-job task mid-pass. + yield* Effect.forEach( + removed, + (row) => stopCommandJob({ id: row.task_id, projectId: row.project_id, prompt: row.prompt }), + { discard: true }, + ); if (removed.length > 0) yield* notifyChanged; }); @@ -1291,6 +1383,11 @@ export const layer = Layer.effect( taskId: input.id, }); } + const jobName = CommandJobs.commandJobName(task.prompt); + if (jobName !== null) { + yield* triggerCommandJob(task, jobName); + return { task, threadId: null }; + } return yield* runTask(task, "manual").pipe( Effect.mapError((cause) => taskError("Could not run schedule task.", { taskId: input.id, cause }), diff --git a/docs/community-patches/native-tick/BRANCH-UPDATES.md b/docs/community-patches/native-tick/BRANCH-UPDATES.md new file mode 100644 index 000000000000..ea318c38041f --- /dev/null +++ b/docs/community-patches/native-tick/BRANCH-UPDATES.md @@ -0,0 +1,35 @@ +# Review fixes after preview.1 + +These changes are on the review branch at source commit [`25ab7ae764471316da5fe74532c159a5cc718fa6`](https://github.com/zainmitchell/t3code/commit/25ab7ae764471316da5fe74532c159a5cc718fa6). [Fork review PR #2](https://github.com/zainmitchell/t3code/pull/2) remains the review record. The original preview.1 tag, ZIP, raw patch and checksum file are unchanged. Downloading preview.1 does not include these later fixes. Build the branch to try the corrected source while review continues. + +## What changed + +The review branch preserves a new launch intent when the orchestrator accepted it before a later launch step failed. The next pass can recover the same work with its pinned launch facts. An unaccepted launch failure still discards the intent. A regression reproduced the accepted-intent loss before the fix. + +A failed pass now examines its coalesced follow-up before finishing. A pending follow-up runs unless the job is stopping or reaches the existing pass cap. With no follow-up, the failure still propagates. Another regression reproduced the lost follow-up before the fix. The scheduler also rechecks coalescing before each busy-task retry, with a separate regression that failed before that change. + +Command-job errors now carry an operation and relevant job/path attributes, preserving the immediate cause. Messages do not copy underlying error text or raw stderr. Nonzero-exit stderr stays in `last-run.log` and `status.json`. Tests verify cause preservation and the separation of the error message from diagnostics. The reviewed I/O helpers use named spans. + +The Windows timeout fixture waits for the captured grandchild through the operating system before probing it. It adds no sleep or polling loop. The crash-harness caller explicitly enables Node type stripping; older supported Node versions were not independently executed in this follow-up. + +The local build-monitor example writes a temporary state file and renames it after the write completes. Direct replay still produced notification counts 0, 1, 1 and no worker requests. This does not make notification appends exactly once or add hard-crash reconciliation. + +The installation guide now explicitly registers the example project with `project add` before `serve` and scheduled-task setup. The built CLI successfully created it in a fresh disposable home. On Windows, use short disposable paths if temporary-directory creation reports `ENAMETOOLONG`. + +## Receipt-read and project-edit follow-up + +The shared `ThreadManagementService.hasReceipt` lookup now preserves receipt-read failures. The command-job runner retains a pending launch intent when the lookup fails, records the recovery error and waits for readable state before replaying it. It deletes intent only after a successful lookup confirms absence. A fresh launch also stops when receipt state is unreadable. MCP delegation and batch-creation callers return an operation error rather than treating unreadable state as confirmed acceptance or absence. + +Editing a running task into another project now stops the old pass using the original task binding. The existing stop conditions for disabling a task or changing its job remain intact. + +Seven targeted regressions failed before these fixes and passed afterward. The final changed-file gate passed all 151 tests in eight executed files, with 12 test paths selected. Formatting, lint, knip, GraphQL checks and typechecking of two affected packages also passed. These counts overlap the earlier 100-test run and must not be added to it. + +The current messaging and tick source/test diffs apply to the official base in either order and produce the same source tree. This was checked using disposable Git indexes. It is source-application proof, not combined runtime acceptance. + +## Earlier verification + +The changed-file gate passed formatting, lint, knip, GraphQL checks, typechecking of two affected packages, and all 100 tests in five executed files. Seven test paths were selected. This includes scheduler and command-job coverage and overlaps the original preview evidence. + +The preview's earlier checks and runtime limitations remain recorded in CHECKS.md and RUNTIME-VERIFICATION.json. A passing follow-up gate is not a full-suite, full-client, real-provider, physical-remote-route, other-OS, existing-state-upgrade or production acceptance claim. No deployment or upstream merge follows from these checks. CodeRabbit's previous review returned findings; assessment and fixes do not establish approval of the new head. + +Implementation and validation of these fixes: GPT-6.1 Sol through Codex in T3 Code. Automated findings: CodeRabbit. The original Claude Opus 5.5 and inherited upstream authorship remain credited in README.md and UPSTREAM.md. diff --git a/docs/community-patches/native-tick/CHECKS.md b/docs/community-patches/native-tick/CHECKS.md new file mode 100644 index 000000000000..5d00de48531a --- /dev/null +++ b/docs/community-patches/native-tick/CHECKS.md @@ -0,0 +1,13 @@ +# Publication checks + +The repository's changed-file check was run against the exact v0.1.2 base on Windows x64, Node 26.12.1, Vite+ 1.3.0: + +```sh +vp run check:changed 044f07257c9684ae4543bd1e1ebf9c394e9e3830 +``` + +The Windows runner needed a local Node adapter to resolve `vp` to the installed Vite+ JavaScript entry point. The running check passed format, knip, generated GraphQL checks, both selected package typechecks, and all 178 tests in six executed files. These tests overlap the earlier 46-test focused evidence; do not add them as unique coverage. + +The initial lint check rejected named Node builtin imports in two documentation examples. The examples were changed to the repository's namespace import style. Final lint and format checks then passed on all delivered source and documents. A direct replay of the final example bytes confirmed idle output and build-monitor notification counts 0, 1, 1. DIRECT-EXAMPLE-VERIFICATION.json distinguishes that replay from the earlier actual scheduler exercise in RUNTIME-VERIFICATION.json. + +The six-file production patch remains byte-identical to the previously exercised archive. No complete repository suite, provider action, other-platform, external CI, or full client acceptance is claimed. diff --git a/docs/community-patches/native-tick/DIRECT-EXAMPLE-VERIFICATION.json b/docs/community-patches/native-tick/DIRECT-EXAMPLE-VERIFICATION.json new file mode 100644 index 000000000000..c0cf3d156de9 --- /dev/null +++ b/docs/community-patches/native-tick/DIRECT-EXAMPLE-VERIFICATION.json @@ -0,0 +1,22 @@ +{ + "execution": "Direct local Node execution of final distributed example bytes; separate from earlier scheduler proof", + "host": "Windows x64", + "node": "26.12.1", + "idleOutput": { + "requests": [] + }, + "monitorInputs": ["pending", "passed", "passed"], + "notificationCounts": [0, 1, 1], + "workerRequests": [], + "externalServiceUsed": false, + "files": [ + { + "path": "examples/check-work.mjs", + "sha256": "6b08970d6b8746b0fe0eafa2b8bde03dd571b943fa1ab718efc2b9ee61e91b81" + }, + { + "path": "examples/build-monitor/watch-build.mjs", + "sha256": "e54d367bb36274b848e5cb6d1be94967a2755bdfef620bda9b310fe3c3150be7" + } + ] +} diff --git a/docs/community-patches/native-tick/INSTALL.md b/docs/community-patches/native-tick/INSTALL.md new file mode 100644 index 000000000000..bf52e17f89ae --- /dev/null +++ b/docs/community-patches/native-tick/INSTALL.md @@ -0,0 +1,70 @@ +# Build and try the Native model-free tick source preview + +The current branch includes [review fixes after preview.1](BRANCH-UPDATES.md). The preview.1 download remains frozen and does not include those fixes. Its MANIFEST.json describes that original patch. Use the branch build below to try the corrected source while review continues. + +This preview targets official T3 Code v0.1.2, commit `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Windows backend setup was exercised with Node 26.12.1 and Vite+ 1.3.0. It provides source code, not a desktop installer or automatic app update. Use disposable state for the first trial. + +## Build the already-patched branch + +Install Node and Vite+ using the [upstream development setup](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/docs/operations/development.md#first-checkout). The recorded exercise used the versions named above. Clone the [feature branch](https://github.com/zainmitchell/t3code/tree/community/native-tick-v0.1.2), then install its pinned dependencies and build the server: + +```sh +git clone --branch community/native-tick-v0.1.2 --single-branch https://github.com/zainmitchell/t3code.git t3-native-tick-example +cd t3-native-tick-example +vp i --frozen-lockfile +vp run --filter t3 build:bundle +``` + +The branch already contains the patch. This path needs no additional `git apply` step. + +## Apply only the patch to an existing clean source checkout + +Alternatively, download the source-patch ZIP from the [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-tick-v0.1.2-preview.1), check its SHA-256 against the Release's checksum file, and extract it outside the checkout. In a clean official source checkout: + +```sh +git checkout --detach v0.1.2 +git rev-parse HEAD +git apply --check /path/to/t3-v0.1.2-native-tick.patch +git apply /path/to/t3-v0.1.2-native-tick.patch +vp i --frozen-lockfile +vp run --filter t3 build:bundle +``` + +The HEAD output before application must be `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Replace `/path/to` with the extracted package directory. MANIFEST.json binds the delivered patch and resulting source-file hashes. The full MIT notice must accompany copies or substantial portions. + +## Start with a new T3 home + +Choose an unused loopback port and new disposable directories. Create the example project directory, then register it in the disposable T3 home before starting the server: + +```sh +node apps/server/dist/bin.mjs project add /path/to/example-project --title "Tick example" --base-dir /path/to/new-example-home +``` + +Record the project ID printed by `project add`. The `serve` command's final directory argument sets the working directory and does not create a project. From the same source checkout, start the server with the same home and project directory: + +```sh +node apps/server/dist/bin.mjs serve --host 127.0.0.1 --port 47831 --base-dir /path/to/new-example-home --no-browser /path/to/example-project +``` + +On Windows, replace both paths with absolute paths. The built CLI uses `--base-dir`; the development runner's `--home-dir` is a different interface. Runtime state lives below `/userdata`. The headless server prints its readiness and pairing details. Those details are credentials and stay private. The source retains upstream package-version metadata; this preview is identified by its source base and patch checksum, not an official application release stamp. + +The recorded exercise used fresh homes, synthetic projects, disabled providers, authenticated application APIs, and scheduler subscriptions. Exact manual UI navigation, a desktop installer, production-state upgrades, real provider actions, physical remote routes, and other operating systems remain unverified. See RUNTIME-VERIFICATION.json and VERIFICATION.json. + +## Try the idle example + +The executable is trusted host code. T3's action limits do not sandbox its filesystem, process, or network access. + +1. In the disposable environment, create a disabled scheduled task for the project registered above with prompt `command-job:check-work`. Record its task ID and use the recorded project ID. The ordinary task model selection remains part of its record, but command-job dispatch does not send this reference as a model prompt. +2. Copy `examples/check-work.mjs` and `examples/command-jobs.example.json` to an example area. Put the configuration at `/userdata/command-jobs.json`. +3. Replace the ID placeholders. Set the actual Node executable, script argument, and `cwd` to absolute paths. Use forward slashes or valid JSON escaping on Windows. `.cmd` and `.bat` launchers are not accepted. +4. Enable or manually run the bound task. Inspect its recorded terminal result. Run-now acknowledges queueing before the pass completes; use the scheduler's status/subscription to observe completion. + +The example reads the supplied snapshot and returns `{"requests":[]}`. The recorded Windows backend exercise observed success without a thread. A missing configured job recorded failure without model fallback. Disabling the task also passed. These were application RPC checks, not manual UI assertions. + +## Try the useful local build monitor + +The `examples/build-monitor/` directory adds a separate deterministic example. Copy its sample build result as `build-result.json` into a writable example directory and use its configuration template. Set the executable to actual Node, the argument to `watch-build.mjs`, and `cwd` to that directory. + +For pending, passed, and repeated passed results, the recorded scheduler runs produced notification counts 0, 1, and 1 in `build-notifications.jsonl`. The example always returns an empty T3 request list. It neither launches nor messages a worker. A separate build service would need to produce the input file. A crash between append and state persistence can repeat a notification; no exactly-once external-side-effect guarantee is made. + +Disable the task when finished and stop the disposable server through the normal procedure used to start it. On POSIX, a command-job process can survive a hard server crash and restart reconciliation for it is not implemented. Normal stop/timeout evidence does not establish arbitrary descendant termination after a hard crash. diff --git a/docs/community-patches/native-tick/LICENSE b/docs/community-patches/native-tick/LICENSE new file mode 100644 index 000000000000..55ee675bb1de --- /dev/null +++ b/docs/community-patches/native-tick/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 T3 Tools Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/community-patches/native-tick/MANIFEST.json b/docs/community-patches/native-tick/MANIFEST.json new file mode 100644 index 000000000000..d86ccaf372c4 --- /dev/null +++ b/docs/community-patches/native-tick/MANIFEST.json @@ -0,0 +1,39 @@ +{ + "feature": "native-tick", + "target": "T3 Code v0.1.2", + "baseCommit": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "patch": "t3-v0.1.2-native-tick.patch", + "patchSha256": "3555bc6ce024e7fa8d26f2a38490ea6ca040275f7d779538eacd1ad09038dc11", + "files": [ + { + "path": "apps/server/src/commandJobs/CommandJobs.integration.test.ts", + "sha256": "281627a4314e387ba7705c2c0add01cf233d6bc2b622fc996d2901e2bee79a67" + }, + { + "path": "apps/server/src/commandJobs/CommandJobs.test.ts", + "sha256": "4d6b6b0894a4d048c5e528af056b8270b04f7406342f73d0688143a300619668" + }, + { + "path": "apps/server/src/commandJobs/CommandJobs.ts", + "sha256": "45afb783bcb9cc899a152059008373d762678aab795392432b3207214cff31b1" + }, + { + "path": "apps/server/src/orchestration-v2/runtimeLayer.ts", + "sha256": "c4a2406d3c0386271cc580eb035a6b1c3150b886808a1305905cd13a39aa4ee8" + }, + { + "path": "apps/server/src/provider/testFixtures/command-job-crash-harness.mjs", + "sha256": "47e4e3631a43f6cc935c745f5b84295e34a5feb2d557f597742d8b6fbb2e7857" + }, + { + "path": "apps/server/src/scheduledTasks/ScheduledTaskService.ts", + "sha256": "b09c4c8e4fbff503dbe1c3a13a2c6fb96c6f6dda20ad5985e7c9ae7924183b9f" + } + ], + "license": "MIT; retain included upstream copyright and permission notice", + "format": "Unified source diff; no contributor history or author metadata included", + "privacyScreen": "Patch and complete changed-file contents screened; no findings", + "artifactScope": "Frozen preview.1 archive and raw patch; these hashes do not describe later branch files.", + "reviewFollowupSourceCommit": "25ab7ae764471316da5fe74532c159a5cc718fa6", + "reviewFollowup": "BRANCH-UPDATES.md" +} diff --git a/docs/community-patches/native-tick/README.md b/docs/community-patches/native-tick/README.md new file mode 100644 index 000000000000..77f540a94b74 --- /dev/null +++ b/docs/community-patches/native-tick/README.md @@ -0,0 +1,103 @@ +# Native model-free ticks for T3 Code v0.1.2 + +The review branch now includes [fixes after the frozen preview.1 download](BRANCH-UPDATES.md), including their verification and remaining limits. The original archive and raw patch are unchanged. + +This standalone community patch lets a scheduled task execute a trusted host-configured command instead of starting a model turn for every check. The command can finish with `{"requests":[]}` and T3 records completion without creating a thread or provider session. When work is needed, it can return validated requests to launch a worker, send a follow-up, or interrupt work it manages. Requested agent work can still consume provider usage. + +## Why this exists + +Many coordination decisions are deterministic: a build is still pending, a known worker has finished, or nothing has changed. A model does not need to reread unchanged status at every interval. A command can inspect those facts and involve an agent only when there is a concrete next action. + +T3 already owns scheduled tasks, projects, permissions, recorded runs and thread actions. Running the check through that service preserves task lifecycle and action validation in the same environment. An external script can still be useful, but by itself it must arrange those T3 bindings and recovery rules separately. This patch adds a service path, not a complete software factory or a replacement provider runtime. + +## When to use it + +- A deterministic status/build/worker check has useful work to do between meaningful agent turns. +- A host owner is willing to configure and trust the executable and bind it to specific tasks/projects. +- A controller needs durable work/send identities and recorded failures when a check cannot run. + +Use an ordinary scheduled prompt when the scheduled activity is itself a model task. Native ticks still consume host resources and may poll an external service. No measured monetary or performance saving is claimed. + +## How it changes T3 + +A scheduled task references a named job, such as `command-job:check-work`. The host-owned configuration fixes the executable, arguments, working directory, timeout, bound project/tasks and action limits. The task prompt cannot choose an arbitrary executable. + +T3 writes a snapshot with recorded workers, launch receipts, run information and previous rejections. It passes input/output paths as `T3_JOB_INPUT` and `T3_JOB_OUTPUT`. The command writes fresh UTF-8 JSON to the new output file. A stale file from another pass must not replay earlier actions. + +T3 validates requests before dispatch. Launches need configured permission/model aliases. Sends and interrupts address managed work. Accepted orchestration receipts and the job's durable receipt records support launch recovery; separate send identities suppress repeated follow-ups. The command and arbitrary external side effects are not guaranteed to execute exactly once. + +Timers and known worker-state changes can request another pass. Triggers during a pass coalesce into a follow-up rather than overlap the same job; long jobs do not block unrelated due tasks. Pausing, changing or removing the bound task can stop its pass. An unrelated task cannot stop it merely by naming the job. Missing configuration, invalid/missing output, failure or timeout records failure instead of falling through to a model prompt. Run-now acknowledges queueing; observe terminal status separately. + +For a software factory, this is a place to run deterministic checks between worker turns. The package does not supply issue intake, claims, capacity accounting, stage acceptance, integration, or a factory connector. + +## T3 and host dependencies + +These are the patch's direct integration dependencies. Their transitive package graph is fixed by the exact official base and its [root manifest](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/package.json), [server manifest](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/package.json), and [lockfile](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/pnpm-lock.yaml). Existing base capabilities do not require their historical PRs to be applied again. + +| Dependency | Why the tick needs it | Where it comes from | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Scheduler and scheduled-task persistence/service | Due/manual runs, task binding, run history, enable/disable/edit/delete lifecycle. | Existing [ScheduledTaskService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/scheduledTasks/ScheduledTaskService.ts), extended by this patch. | +| Command-job runner and runtime wiring | Choose the command path, coalesce wakeups, validate requests, and wire the service. | Bundled `CommandJobs.ts`, scheduler changes and `runtimeLayer.ts`. | +| ProjectService | Check the bound project and workspace used by the job. | Existing [ProjectService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/project/ProjectService.ts). | +| ThreadLaunchService | Launch requested workers and use accepted command receipts for recovery/deduplication. | Existing [ThreadLaunchService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/orchestration-v2/ThreadLaunchService.ts). | +| ThreadManagementService | Read managed workers/runs, send follow-ups and request interrupts. | Existing [ThreadManagementService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/orchestration-v2/ThreadManagementService.ts). | +| Contracts and DispatchModeLimit | Validate project/task/model identities and requested runtime/interaction ceilings. | Existing [contracts](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/packages/contracts/src/index.ts) and [DispatchModeLimit](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/orchestration-v2/DispatchModeLimit.ts). | +| ProcessRunner | Execute the configured command and apply timeout/stop behavior. | Existing [processRunner](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/processRunner.ts). | +| ServerConfig, Effect FileSystem/Path/Crypto and atomic writes | Locate host config and preserve per-job input/output/receipt state. | Existing server/runtime dependencies and [atomicWrite](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/packages/shared/src/atomicWrite.ts). | +| Host job configuration and executable | Supply `command-jobs.json`, a permitted task/project, executable, arguments, working directory and timeout. | Supplied by the host owner; see [INSTALL.md](INSTALL.md). | +| Provider/model selection for requested agent work | Needed when the job requests worker actions. An idle pass opens no provider session. | Existing T3 provider configuration and configured model aliases. Real provider actions were not exercised here. | + +The complete six-file delta is the command-job service, its two test files, scheduler integration, runtime wiring, and the crash fixture, as listed in [MANIFEST.json](MANIFEST.json). It uses existing Effect/platform/test dependencies. **No native messaging PR, #22445–#22448 stack, factory package, new npm dependency, lockfile change, or database migration is required.** That standalone property was exercised against the clean official base. + +## A useful example and observed behavior + +The original idle example reads the snapshot and returns no requests. The included [build monitor](examples/build-monitor/README.md) reads a local synthetic build-result file, records a notification when a terminal result changes, and persists its fingerprint. Actual scheduler runs for pending, passed, repeated passed inputs produced notification counts **0, 1, 1**. It always returns an empty request list; no worker was launched or messaged and no external CI service was connected. + +A separate build service would need to supply the input file. A crash between appending a notification and saving its fingerprint can repeat a notification. The example demonstrates useful local work without idle model turns, not production CI acceptance or complete autonomy. + +## Verification and limitations + +Windows x64 preparation used Node 26.12.1 and Vite+ 1.3.0. The package passed 46 focused tests across four files, server typecheck/static checks and server bundling. A later selected run passed 178 tests and relevant typechecks. Those counts overlap and must not be added as unique coverage. Final lint/format passed after example import cleanup; the exact final examples also passed a direct replay. + +A disposable installation exercise reconstructed the standalone archive on the exact official base, installed pinned dependencies, built the server, and observed idle success, missing-job failure without model fallback, task disablement, and the monitor sequence through authenticated application RPC and scheduler subscriptions. All providers stayed disabled and thread/session projections stayed empty. See [VERIFICATION.json](VERIFICATION.json), [RUNTIME-VERIFICATION.json](RUNTIME-VERIFICATION.json), [CHECKS.md](CHECKS.md), and [DIRECT-EXAMPLE-VERIFICATION.json](DIRECT-EXAMPLE-VERIFICATION.json). + +The executable is trusted host code. T3 action limits do not sandbox its filesystem, process or network access. On POSIX, a job can survive a hard server crash; restart reconciliation is not implemented. Normal stop/timeout checks do not establish arbitrary descendant termination after a hard crash. + +Real model calls, worker-directed actions, external CI integration, other operating systems, full web/desktop/mobile acceptance, desktop installation and production-state upgrades remain unverified. Focused backend evidence is not a full application-acceptance claim. CodeRabbit/fork-local PR review is a separate state, not implied by publishing the release. + +## Source and installation + +Use the [applied source branch](https://github.com/zainmitchell/t3code/tree/community/native-tick-v0.1.2) or [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-tick-v0.1.2-preview.1). The exact base is official v0.1.2 at `044f07257c9684ae4543bd1e1ebf9c394e9e3830`; other versions need separate compatibility checks. The release supplies source, manifest, license, examples, guides, evidence and checksums. It requires a source build and host job configuration, not a skill import, prompt, desktop installer or automatic update. Follow [INSTALL.md](INSTALL.md) with disposable state first. + +## Related proposals and public history + +This publication has not opened an upstream native-tick PR, so it has no upstream open/merged/closed tick PR history to claim. The existing capabilities it uses are already in the pinned T3 base. No closed messaging PR is a tick prerequisite. + +[Scheduled prompts #6748](https://github.com/pingdotgg/t3code/discussions/6748) and [recurring thread runs #11654](https://github.com/pingdotgg/t3code/discussions/11654) are related discussions, not prerequisite PRs. They wake agent turns. This command path can complete without one. A bounded search of related public PR text is not an exhaustive claim that no one else has proposed similar work. + +| Public milestone | Record | Scope | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Official source baseline | [v0.1.2 / 044f0725](https://github.com/pingdotgg/t3code/tree/044f07257c9684ae4543bd1e1ebf9c394e9e3830) | Existing scheduler, orchestration, project, provider and host-runtime dependencies. | +| Standalone source preview | [a2f8cd1e](https://github.com/zainmitchell/t3code/commit/a2f8cd1ef36d456c64b9f298b71f5364deaea742) | Six-file patch, build guide, idle/build-monitor examples and evidence. | +| Attribution and maintenance clarification | [4cc20cab](https://github.com/zainmitchell/t3code/commit/4cc20cabdcd5c4f4ab634c63a37d8617ec4ac050) | Documentation; production patch unchanged. | +| Dependency/history consolidation | This guide and its Git history. | Documentation; original release bytes remain unchanged. | +| Fork-local code review | [Feature PR queue](https://github.com/zainmitchell/t3code/pulls?q=is%3Apr+head%3Acommunity%2Fnative-tick-v0.1.2). | Review is separate from upstream contribution; requested/completed states and exact reviewed head must be reported. | + +Useful feedback is a deterministic check you want to run, a configuration/recovery concern, or a reproducible failure at a named revision. Feature direction and any eventual upstream PR split still need maintainer agreement. + +## Maintenance, upstream intent, and bug reports + +This is a temporary community patch fork for free, self-hosted open-source use. The owner maintains the patches and tracks bugs in [the fork's Issues](https://github.com/zainmitchell/t3code/issues) until T3 ships the capability or a better replacement. The intent is to retire these fork-specific changes as upstream meets the need. + +We welcome upstream adoption through maintainer-approved direction and focused contributions. The stable bundle is a distribution unit, not a demand to merge that entire bundle as one upstream PR. This work carries no upstream endorsement, approval, or merge commitment. Reports should identify the source base, patch/version, reproduction, expected and observed behavior, and suitably redacted evidence. + +The [MIT notice](LICENSE) must accompany copies or substantial portions. Retain applicable dependency notices when distributing a built application. No paid hosting or application installer is provided by this preview. + +## AI models and roles + +| Model | Work credited here | Harness | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | +| Claude Opus 5.5 | Fork implementation/integration work; original T3 source retains its upstream authorship. | T3 Code's Claude provider. | +| GPT-6.1 Sol | Source extraction, limited cleanup, source/evidence and documentation review, focused verification, examples, and publication preparation. Native tick also received a documentation review with this model. | Codex in T3 Code. | + +These credits do not claim a new independent audit of every upstream revision. Historical upstream model credits remain attached to their own PRs. CodeRabbit review of this fork's source has not yet been established; a review request, a bot status, and a completed review are different states. diff --git a/docs/community-patches/native-tick/RUNTIME-VERIFICATION.json b/docs/community-patches/native-tick/RUNTIME-VERIFICATION.json new file mode 100644 index 000000000000..e450efd9fda6 --- /dev/null +++ b/docs/community-patches/native-tick/RUNTIME-VERIFICATION.json @@ -0,0 +1,55 @@ +{ + "sourceBase": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "patchSha256": "3555bc6ce024e7fa8d26f2a38490ea6ca040275f7d779538eacd1ad09038dc11", + "host": "Windows x64", + "node": "26.12.1", + "feature": "native-tick", + "observed": { + "cleanArchiveApply": "passed; identical prior standalone source tree", + "offlinePinnedDependencyInstall": "passed", + "sourceBundleBuild": "passed", + "freshHomeStartup": "passed", + "authenticatedClientRpc": "passed", + "idleSchedulerRun": "succeeded without a thread", + "missingJob": "failed without a thread or model fallback", + "completionObservation": "scheduler subscription, after queue acknowledgement", + "disableTask": "passed", + "buildMonitorNotificationCounts": [0, 1, 1], + "threadAndSessionProjections": 0 + }, + "enabledProviders": 0, + "proofServersStopped": true, + "notVerified": [ + "Manual task-creation UI steps", + "Desktop installation or production-state upgrade", + "Real provider turns or worker-directed actions", + "External CI integration", + "Physical host-to-host routes or T3 Connect", + "Other operating systems", + "Full client/device acceptance", + "MCP OAuth client acceptance" + ], + "scope": "Windows disposable backend proof; no live production installation", + "additionalBuildMonitor": { + "sourceBase": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "tickPatchSha256": "3555bc6ce024e7fa8d26f2a38490ea6ca040275f7d779538eacd1ad09038dc11", + "demoScriptSha256": "af797bffee943b28acdab3a5b0ff42c3d2e5bf2d54c7c25783ac03aa969c8b9e", + "host": "Windows x64", + "node": "26.12.1", + "execution": "Configured command job through authenticated application RPC; completion awaited on scheduler subscription", + "inputs": ["pending", "passed", "passed"], + "notificationCounts": [0, 1, 1], + "schedulerRunStatuses": ["succeeded", "succeeded", "succeeded"], + "enabledProviders": 0, + "threadAndSessionProjections": 0, + "workerRequests": [], + "externalBuildServiceUsed": false, + "limits": [ + "No worker-directed action or real provider turn", + "No external CI integration", + "Normal-repeat suppression only; crash can repeat a notification", + "Windows backend proof only" + ], + "originalTickArchiveUnchanged": true + } +} diff --git a/docs/community-patches/native-tick/UPSTREAM.md b/docs/community-patches/native-tick/UPSTREAM.md new file mode 100644 index 000000000000..febe53b71613 --- /dev/null +++ b/docs/community-patches/native-tick/UPSTREAM.md @@ -0,0 +1,34 @@ +# Dependencies and history + +## T3 and host dependencies + +These are the patch's direct integration dependencies. Their transitive package graph is fixed by the exact official base and its [root manifest](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/package.json), [server manifest](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/package.json), and [lockfile](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/pnpm-lock.yaml). Existing base capabilities do not require their historical PRs to be applied again. + +| Dependency | Why the tick needs it | Where it comes from | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Scheduler and scheduled-task persistence/service | Due/manual runs, task binding, run history, enable/disable/edit/delete lifecycle. | Existing [ScheduledTaskService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/scheduledTasks/ScheduledTaskService.ts), extended by this patch. | +| Command-job runner and runtime wiring | Choose the command path, coalesce wakeups, validate requests, and wire the service. | Bundled `CommandJobs.ts`, scheduler changes and `runtimeLayer.ts`. | +| ProjectService | Check the bound project and workspace used by the job. | Existing [ProjectService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/project/ProjectService.ts). | +| ThreadLaunchService | Launch requested workers and use accepted command receipts for recovery/deduplication. | Existing [ThreadLaunchService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/orchestration-v2/ThreadLaunchService.ts). | +| ThreadManagementService | Read managed workers/runs, send follow-ups and request interrupts. | Existing [ThreadManagementService](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/orchestration-v2/ThreadManagementService.ts). | +| Contracts and DispatchModeLimit | Validate project/task/model identities and requested runtime/interaction ceilings. | Existing [contracts](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/packages/contracts/src/index.ts) and [DispatchModeLimit](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/orchestration-v2/DispatchModeLimit.ts). | +| ProcessRunner | Execute the configured command and apply timeout/stop behavior. | Existing [processRunner](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/apps/server/src/processRunner.ts). | +| ServerConfig, Effect FileSystem/Path/Crypto and atomic writes | Locate host config and preserve per-job input/output/receipt state. | Existing server/runtime dependencies and [atomicWrite](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/packages/shared/src/atomicWrite.ts). | +| Host job configuration and executable | Supply `command-jobs.json`, a permitted task/project, executable, arguments, working directory and timeout. | Supplied by the host owner; see [INSTALL.md](INSTALL.md). | +| Provider/model selection for requested agent work | Needed when the job requests worker actions. An idle pass opens no provider session. | Existing T3 provider configuration and configured model aliases. Real provider actions were not exercised here. | + +The frozen preview.1 download contains the original six-file delta: the command-job service, its two test files, scheduler integration, runtime wiring, and the crash fixture, as listed in [MANIFEST.json](MANIFEST.json). The current review branch also includes the [later recovery and lifecycle fixes](BRANCH-UPDATES.md). Its receipt-read fix changes the existing ThreadManagementService contract and MCP callers, with their regression tests included in the branch. Those changes are bundled here and require no additional upstream PR. It uses existing Effect/platform/test dependencies. **No native messaging PR, #22445–#22448 stack, factory package, new npm dependency, lockfile change, or database migration is required.** That standalone property was exercised against the clean official base. + +## Related proposals and public history + +This publication has not opened an upstream native-tick PR, so it has no upstream open/merged/closed tick PR history to claim. The existing capabilities it uses are already in the pinned T3 base. No closed messaging PR is a tick prerequisite. + +[Scheduled prompts #6748](https://github.com/pingdotgg/t3code/discussions/6748) and [recurring thread runs #11654](https://github.com/pingdotgg/t3code/discussions/11654) are related discussions, not prerequisite PRs. They wake agent turns. This command path can complete without one. A bounded search of related public PR text is not an exhaustive claim that no one else has proposed similar work. + +| Public milestone | Record | Scope | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Official source baseline | [v0.1.2 / 044f0725](https://github.com/pingdotgg/t3code/tree/044f07257c9684ae4543bd1e1ebf9c394e9e3830) | Existing scheduler, orchestration, project, provider and host-runtime dependencies. | +| Standalone source preview | [a2f8cd1e](https://github.com/zainmitchell/t3code/commit/a2f8cd1ef36d456c64b9f298b71f5364deaea742) | Six-file patch, build guide, idle/build-monitor examples and evidence. | +| Attribution and maintenance clarification | [4cc20cab](https://github.com/zainmitchell/t3code/commit/4cc20cabdcd5c4f4ab634c63a37d8617ec4ac050) | Documentation; production patch unchanged. | +| Dependency/history consolidation | This guide and its Git history. | Documentation; original release bytes remain unchanged. | +| Fork-local code review | [Feature PR queue](https://github.com/zainmitchell/t3code/pulls?q=is%3Apr+head%3Acommunity%2Fnative-tick-v0.1.2). | Review is separate from upstream contribution; requested/completed states and exact reviewed head must be reported. | diff --git a/docs/community-patches/native-tick/VERIFICATION.json b/docs/community-patches/native-tick/VERIFICATION.json new file mode 100644 index 000000000000..b2e628d39b16 --- /dev/null +++ b/docs/community-patches/native-tick/VERIFICATION.json @@ -0,0 +1,37 @@ +{ + "target": "Official T3 Code v0.1.2 source", + "baseCommit": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "patchSha256": "3555bc6ce024e7fa8d26f2a38490ea6ca040275f7d779538eacd1ad09038dc11", + "host": "Windows x64", + "node": "26.12.1", + "focused": { + "tests": 46, + "files": 4, + "exitCode": 0 + }, + "supplemental": null, + "postCleanupPeerRecheck": null, + "typecheck": "passed", + "format": "passed", + "lint": "passed with warnings", + "unusedCodeCheck": "passed", + "serverBundle": "passed", + "applyChecks": "passed alone and in either order with the other package; identical combined tree", + "combinedFocused": { + "tests": 242, + "files": 12, + "exitCode": 0 + }, + "combinedTypecheck": "passed", + "combinedServerBundle": "passed", + "exampleSmoke": "passed; empty request list", + "notVerified": [ + "Full repository test suite", + "Physical cross-machine routes or real T3 Connect", + "Real provider/model execution", + "Browser/desktop/mobile runtime behavior", + "Other operating systems", + "Installation against existing production state" + ], + "distribution": "Source patch only. No application binary, private evidence, account identity, or fork history." +} diff --git a/docs/community-patches/native-tick/examples/build-monitor/LICENSE b/docs/community-patches/native-tick/examples/build-monitor/LICENSE new file mode 100644 index 000000000000..55ee675bb1de --- /dev/null +++ b/docs/community-patches/native-tick/examples/build-monitor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 T3 Tools Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/community-patches/native-tick/examples/build-monitor/README.md b/docs/community-patches/native-tick/examples/build-monitor/README.md new file mode 100644 index 000000000000..5d275678f1e3 --- /dev/null +++ b/docs/community-patches/native-tick/examples/build-monitor/README.md @@ -0,0 +1,17 @@ +# Record a changed build result without a model turn + +This additional source example monitors a local `build-result.json` file. When a revision changes to `passed` or `failed`, it appends a notification to `build-notifications.jsonl`. Repeating the same revision and status does not append another notification during normal runs. + +Use `watch-build.mjs` as a trusted host-configured command job. Set the actual Node executable and the script's absolute path in `command-jobs.json`. Set `cwd` to a writable example directory containing the build result. Bind the configured project and task IDs as described by the standalone tick guide. Keep this trial in disposable state. + +Copy build-result.example.json to the writable example directory as build-result.json. Use command-jobs.example.json as a template and replace its paths and IDs. + +An example build result is `{"revision":"example-revision-1","status":"pending"}`. Change the status to `passed` to record one notification. The script reads T3's snapshot and returns `{"requests":[]}` on every pass. It creates no worker and sends no message. A separate build system would have to produce the input file. + +The standalone Windows runtime exercise invoked this example through T3's scheduler for pending, passed, and repeated passed results. Notification counts were 0, 1, and 1. All three runs succeeded with zero T3 threads or provider-session projections. This proves the local file-monitor behavior, not an external CI integration or an accepted worker-directed action. + +The host program is trusted code, not sandboxed by T3 action limits. A crash between appending the notification and writing its state can repeat a notification. No exactly-once external-side-effect guarantee is made. The original prepared tick archive is preserved. This preview distribution includes the original idle sample and this additional example. + +The complete MIT notice is in LICENSE. The source preview is available from the [Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-tick-v0.1.2-preview.1). + +The final distributed script has formatting and namespace-import cleanup. Its exact digest and direct replay are recorded in [DIRECT-EXAMPLE-VERIFICATION.json](../../DIRECT-EXAMPLE-VERIFICATION.json). The earlier scheduler proof retains the original script digest. diff --git a/docs/community-patches/native-tick/examples/build-monitor/VERIFICATION.json b/docs/community-patches/native-tick/examples/build-monitor/VERIFICATION.json new file mode 100644 index 000000000000..f1460cf325ae --- /dev/null +++ b/docs/community-patches/native-tick/examples/build-monitor/VERIFICATION.json @@ -0,0 +1,24 @@ +{ + "sourceBase": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "tickPatchSha256": "3555bc6ce024e7fa8d26f2a38490ea6ca040275f7d779538eacd1ad09038dc11", + "demoScriptSha256": "af797bffee943b28acdab3a5b0ff42c3d2e5bf2d54c7c25783ac03aa969c8b9e", + "host": "Windows x64", + "node": "26.12.1", + "execution": "Configured command job through authenticated application RPC; completion awaited on scheduler subscription", + "inputs": ["pending", "passed", "passed"], + "notificationCounts": [0, 1, 1], + "schedulerRunStatuses": ["succeeded", "succeeded", "succeeded"], + "enabledProviders": 0, + "threadAndSessionProjections": 0, + "workerRequests": [], + "externalBuildServiceUsed": false, + "limits": [ + "No worker-directed action or real provider turn", + "No external CI integration", + "Normal-repeat suppression only; crash can repeat a notification", + "Windows backend proof only" + ], + "originalTickArchiveUnchanged": true, + "distributedExampleSha256": "e54d367bb36274b848e5cb6d1be94967a2755bdfef620bda9b310fe3c3150be7", + "exampleCleanup": "Formatting and namespace Node imports only; final direct replay recorded separately in ../../DIRECT-EXAMPLE-VERIFICATION.json" +} diff --git a/docs/community-patches/native-tick/examples/build-monitor/build-result.example.json b/docs/community-patches/native-tick/examples/build-monitor/build-result.example.json new file mode 100644 index 000000000000..2f9acc1c3487 --- /dev/null +++ b/docs/community-patches/native-tick/examples/build-monitor/build-result.example.json @@ -0,0 +1,4 @@ +{ + "revision": "example-revision-1", + "status": "pending" +} diff --git a/docs/community-patches/native-tick/examples/build-monitor/command-jobs.example.json b/docs/community-patches/native-tick/examples/build-monitor/command-jobs.example.json new file mode 100644 index 000000000000..109cd757c545 --- /dev/null +++ b/docs/community-patches/native-tick/examples/build-monitor/command-jobs.example.json @@ -0,0 +1,12 @@ +{ + "jobs": { + "check-work": { + "projectId": "replace-with-your-example-project-id", + "taskIds": ["replace-with-your-disabled-task-id"], + "executable": "/absolute/path/to/node", + "args": ["/absolute/path/to/watch-build.mjs"], + "cwd": "/absolute/path/to/writable-example-directory", + "timeoutMs": 10000 + } + } +} diff --git a/docs/community-patches/native-tick/examples/build-monitor/watch-build.mjs b/docs/community-patches/native-tick/examples/build-monitor/watch-build.mjs new file mode 100644 index 000000000000..3254b8fe2218 --- /dev/null +++ b/docs/community-patches/native-tick/examples/build-monitor/watch-build.mjs @@ -0,0 +1,39 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; + +const inputPath = process.env.T3_JOB_INPUT; +const outputPath = process.env.T3_JOB_OUTPUT; +if (!inputPath || !outputPath) throw new Error("Run this example as a configured T3 command job."); + +const snapshot = JSON.parse(NodeFS.readFileSync(inputPath, "utf8")); +const build = JSON.parse(NodeFS.readFileSync("build-result.json", "utf8")); +if (typeof build.revision !== "string" || !build.revision.trim()) + throw new Error("A build revision is required."); +if (!["pending", "passed", "failed"].includes(build.status)) + throw new Error("Unsupported build status."); +if (!Array.isArray(snapshot.workers)) throw new Error("The job input must contain workers."); + +const fingerprint = NodeCrypto.createHash("sha256") + .update(JSON.stringify([build.revision, build.status])) + .digest("hex"); +let previous; +try { + previous = JSON.parse(NodeFS.readFileSync(".build-monitor-state.json", "utf8")); +} catch (error) { + if (error.code !== "ENOENT") throw error; +} +if (build.status !== "pending" && previous?.fingerprint !== fingerprint) { + NodeFS.appendFileSync( + "build-notifications.jsonl", + `${JSON.stringify({ + revision: build.revision, + status: build.status, + recordedWorkers: snapshot.workers.length, + })}\n`, + "utf8", + ); +} +const temporaryState = `.build-monitor-state.${process.pid}.tmp`; +NodeFS.writeFileSync(temporaryState, `${JSON.stringify({ fingerprint })}\n`, "utf8"); +NodeFS.renameSync(temporaryState, ".build-monitor-state.json"); +NodeFS.writeFileSync(outputPath, `${JSON.stringify({ requests: [] })}\n`, "utf8"); diff --git a/docs/community-patches/native-tick/examples/check-work.mjs b/docs/community-patches/native-tick/examples/check-work.mjs new file mode 100644 index 000000000000..e9f21eeb37c2 --- /dev/null +++ b/docs/community-patches/native-tick/examples/check-work.mjs @@ -0,0 +1,11 @@ +import * as NodeFS from "node:fs"; + +const inputPath = process.env.T3_JOB_INPUT; +const outputPath = process.env.T3_JOB_OUTPUT; +if (!inputPath || !outputPath) { + throw new Error("Run this example as a configured T3 command job."); +} + +const snapshot = JSON.parse(NodeFS.readFileSync(inputPath, "utf8")); +NodeFS.writeFileSync(outputPath, `${JSON.stringify({ requests: [] })}\n`, "utf8"); +console.log(`Checked ${snapshot.workers.length} recorded workers. No action requested.`); diff --git a/docs/community-patches/native-tick/examples/command-jobs.example.json b/docs/community-patches/native-tick/examples/command-jobs.example.json new file mode 100644 index 000000000000..f45411a5c3eb --- /dev/null +++ b/docs/community-patches/native-tick/examples/command-jobs.example.json @@ -0,0 +1,12 @@ +{ + "jobs": { + "check-work": { + "projectId": "replace-with-your-project-id", + "taskIds": ["replace-with-your-disabled-task-id"], + "executable": "/absolute/path/to/node", + "args": ["/absolute/path/to/check-work.mjs"], + "cwd": "/absolute/path/to/example-directory", + "timeoutMs": 10000 + } + } +}