Skip to content
27 changes: 19 additions & 8 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,13 @@ schema bindings, and metadata projections remain provenance for inspection;
they are not resume authorization. Smithers decides which finished rows can be
reused and which newly rendered or unfinished tasks run. Ultrafuzz does not
rewrite historical artifacts or automatically reset, replay, timetravel, or
fork completed work.
fork completed work. When the run's `smithers/resolved-config.json` parses as
the current resolved-config schema, agent adapters in the continued workflow
read the run's `smithers/execution-config.toml` (launch gave them a copy of the
same file); for a run whose resolved config does not parse, resume sets no
`ULTRAFUZZ_CONFIG_PATH`. If resume cannot prune stale task-worktree
registrations, it reports a `WORKFLOW_WORKTREE_REPAIR_FAILED` warning and
continues.

`resume --refresh-controller` first renders the currently installed Ultrafuzz
controller and stock adapters beside the historical source, then delegates to
Expand Down Expand Up @@ -458,13 +464,18 @@ an available agent-written report.

`pause` requests a graceful stop: no new tasks are scheduled, in-flight tasks
finish, and the run settles in the resumable `paused` state. `resume` reports
`submitted: false` instead of launching a duplicate continuation when the linked
workflow is still in an active state (running, in-progress, started, queued,
retrying, or waiting). `resume --reset-node` retries one failed workflow node and
its dependents in the same linked run; the applied reset is recorded so retrying
the command after a failed continuation resumes the already-reset run instead of
repeating the reset. `fork` may start from a checkpoint frame and may reset one
workflow node before starting the fork.
`submitted: false` (text output `Run already active`) instead of launching a
duplicate continuation when the linked workflow is still active (its Smithers
run state is `running`, `recovering`, or one of the `waiting-*` states), and
leaves the run's recorded state and workflow deadline unchanged. A run still
finishing its in-flight tasks after `pause` is still active; resume it again
once `status` reports `paused`. Smithers also reports a run as `running` for up
to 30 seconds after its controller process exits (its heartbeat window), so
resume such a run again after that. `resume --reset-node` retries one failed
workflow node and its dependents in the same linked run; the applied reset is
recorded so retrying the command after a failed continuation resumes the
already-reset run instead of repeating the reset. `fork` may start from a
checkpoint frame and may reset one workflow node before starting the fork.

Every command in this section takes an Ultrafuzz run ID and resolves the linked
workflow run from existing product evidence; none of them require the
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ resume.

`workflow_deadline_seconds` is not a guaranteed wall-clock limit. Ultrafuzz
records `workflow_deadline_at` in run state when the run is created, and again
from each `resume`, `replay`, or `fork`, but nothing enforces it on a timer: an
from each `replay`, `fork`, or `resume` that starts a controller (not one that
finds the run still active), but nothing enforces it on a timer: an
unattended run keeps executing, and incurring provider cost, past its deadline.
The deadline is checked only when a command synchronizes the run: `ultrafuzz
status` (including each `--watch` poll), `inspect`, `why`, and `stats`, plus
Expand Down
8 changes: 7 additions & 1 deletion docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,13 @@ a working-tree rebuild cannot change an active run. Ambient Node loader/search
variables are removed and both ESM and CommonJS module resolution must stay
inside that snapshot; document reads are unaffected. A path lookup alone is not
a preflight. Ordinary resume now delegates continuation to Smithers instead of
using the historical launcher or closure as an authorization gate. A current
using the historical launcher or closure as an authorization gate. When resume
cannot re-verify the launcher, it reports a `WORKFLOW_TRUSTED_CLI_UNVERIFIED`
warning and, if `<run>/trusted-bin/ultrafuzz` exists, keeps it first on `PATH`
rather than letting tasks reach another `ultrafuzz`. That launcher still
verifies its closure before every dispatch, so if its metadata or closure is
damaged, or the Node binary it names is gone, each task's validator preflight
fails; `resume --refresh-controller` does not repair such a launcher. A current
controller refresh publishes a new controller path without rewriting the
historical closure.

Expand Down
14 changes: 9 additions & 5 deletions packages/cli/src/commands/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
cliEntrypoint,
cliIo,
commandFromRuntime,
diagnosticsText,
emitCommandResult,
globalFlags,
projectRoot
Expand Down Expand Up @@ -37,11 +38,14 @@ export default class Resume extends Command {
resetNode: flags["reset-node"],
env: cliIo().env
});
emitCommandResult(
this,
"resume",
commandFromRuntime("resume", result, (value) => `Submitted ${value.action}: ${value.workflow_run_id}\n`),
flags.json === true
const commandResult = commandFromRuntime("resume", result, (value) =>
value.submitted
? `Submitted ${value.action}: ${value.workflow_run_id}\n`
: `Run already active: ${value.workflow_run_id}; no new controller was started. If a pause is still draining, resume again once status reports paused; if its controller process just exited, resume again after 30 seconds.\n`
);
if (result.ok && result.diagnostics.length > 0) {
commandResult.text = `${commandResult.text ?? ""}${diagnosticsText(result.diagnostics)}`;
}
emitCommandResult(this, "resume", commandResult, flags.json);
}
}
20 changes: 20 additions & 0 deletions packages/cli/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1918,6 +1918,26 @@ test("status surfaces a terminal product and live workflow lifecycle divergence"
);
});

test("resume of an already-active run says no controller was started instead of claiming a submission", async () => {
const project = tempProject();
const env = fakeSmithersEnv(project);
assert.equal((await cli(project, ["init", "--json"], env)).code, 0);
writeSmallTopology(project);
const runId = "resume-already-active";
const run = await cli(project, ["run", "--run-id", runId, "--json"], env);
assert.equal(run.code, 0, run.stderr);

// The fake runner still reports the run as running, so resume only attaches.
const resumed = await cli(project, ["resume", runId], env);

assert.equal(resumed.code, 0, `${resumed.stderr}\n${resumed.stdout}`);
assert.match(
resumed.stdout,
/^Run already active: ultrafuzz-resume-already-active; no new controller was started\./mu
);
assert.doesNotMatch(resumed.stdout, /Submitted/u);
});

test("status --watch stops immediately on a degraded verdict even while product state is nonterminal", async () => {
const project = tempProject();
const env = fakeSmithersEnv(project);
Expand Down
103 changes: 72 additions & 31 deletions packages/runtime/src/start-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
prepareTrustedCliEnvironment,
runTrustedJsonValidatorPreflight,
TRUSTED_CLI_ENVIRONMENT_VARIABLES,
ULTRAFUZZ_TRUSTED_BIN_ENV,
type TrustedCliEnvironment
} from "./trusted-cli.js";
import { hasRuntimeErrors, runtimeFailure, runtimeResult } from "./utils.js";
Expand Down Expand Up @@ -478,6 +479,7 @@ export async function resumeRun(input: WorkflowLifecycleInput) {

async function submitSmithersContinuation(input: WorkflowLifecycleInput) {
let releaseLifecycleLock: (() => Promise<void>) | undefined;
const diagnostics: RuntimeDiagnostic[] = [];
try {
const projectRoot = path.resolve(input.projectRoot);
const runsRoot = await runsRootForProject(projectRoot);
Expand Down Expand Up @@ -526,6 +528,10 @@ async function submitSmithersContinuation(input: WorkflowLifecycleInput) {
const smithersRoot = safeResolveInside(layout.root, "smithers", "Smithers evidence");
const tasksPath = safeResolveInside(smithersRoot, "tasks.json", "workflow task manifest");
const configPath = safeResolveInside(smithersRoot, "resolved-config.json", "workflow config");
// Agent adapters parse ULTRAFUZZ_CONFIG_PATH as TOML; given the JSON above
// they find no agent tables and fall back to default auth. Launch writes
// the same config as TOML beside it and hands adapters a copy of that file.
const agentConfigPath = safeResolveInside(smithersRoot, "execution-config.toml", "workflow agent config");
let taskDocument: SmithersTaskManifestDocument | undefined;
let config: ResolvedConfig | undefined;
if (fs.existsSync(tasksPath)) {
Expand Down Expand Up @@ -571,7 +577,7 @@ async function submitSmithersContinuation(input: WorkflowLifecycleInput) {
...forgeGuard.env,
ULTRAFUZZ_ARTIFACTS_MODULE: import.meta.resolve("@ultrafuzz/artifacts"),
ULTRAFUZZ_RUNTIME_MODULE: import.meta.resolve("@ultrafuzz/runtime"),
...(config === undefined ? {} : { ULTRAFUZZ_CONFIG_PATH: configPath }),
...(config === undefined ? {} : { ULTRAFUZZ_CONFIG_PATH: agentConfigPath }),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
ULTRAFUZZ_WORKFLOW_PERSISTED_PATH: workflowPath
};
let trustedCli: TrustedCliEnvironment = {
Expand All @@ -593,9 +599,27 @@ async function submitSmithersContinuation(input: WorkflowLifecycleInput) {
});
if (prepared.active) runTrustedJsonValidatorPreflight({ layout, trusted: prepared });
trustedCli = prepared;
} catch {
} catch (error) {
// Historical validator identity is task setup provenance, not authority
// to prevent Smithers from continuing the workflow.
// to prevent Smithers from continuing the workflow. Keep the run-owned
// launcher first on PATH anyway: it re-verifies its closure on every
// call, while dropping it lets tasks run whatever `ultrafuzz` is on PATH.
const launcher = path.join(
layout.root,
"trusted-bin",
process.platform === "win32" ? "ultrafuzz.cmd" : "ultrafuzz"
);
const launcherKept = fs.existsSync(launcher);
if (launcherKept) trustedCli.env[ULTRAFUZZ_TRUSTED_BIN_ENV] = path.dirname(launcher);
diagnostics.push(
resumeWarning(
"WORKFLOW_TRUSTED_CLI_UNVERIFIED",
launcherKept
? `resume could not re-verify the run's trusted Ultrafuzz CLI (tasks still call ${launcher}, and their preflight-json-validator step fails while that launcher cannot verify itself)`
: `resume could not re-verify the run's trusted Ultrafuzz CLI (${launcher} does not exist, so tasks call whatever \`ultrafuzz\` is on PATH)`,
error
)
);
}
}
const agentRefs = tasks.flatMap((task) => task.agentChain.map((profile) => profile.agentRef));
Expand All @@ -611,7 +635,15 @@ async function submitSmithersContinuation(input: WorkflowLifecycleInput) {
assertCurrentCloudAgentCredentialEnvironment(config, tasks, lifecycleEnvironment);
}
if (typeof metadata.source_revision === "string") {
repairPrunableRunWorktreeRegistrations({ projectRoot, runRoot: layout.root, runId });
try {
repairPrunableRunWorktreeRegistrations({ projectRoot, runRoot: layout.root, runId });
} catch (error) {
// Pruning is cleanup: a stale registration it leaves behind surfaces
// when Smithers recreates that task's worktree, so do not stop here.
diagnostics.push(
resumeWarning("WORKFLOW_WORKTREE_REPAIR_FAILED", "resume could not prune stale task worktrees", error)
);
}
}
const result = await runSmithersLifecycleCommand({
action: "resume",
Expand Down Expand Up @@ -668,20 +700,27 @@ async function submitSmithersContinuation(input: WorkflowLifecycleInput) {
trustedCli.environmentVariableNames
)
});
recordNativeContinuationState({
layout,
config,
requestedConcurrency: input.maxConcurrency,
alreadyRunning: result.alreadyRunning ?? false
});
return runtimeResult(true, {
run_id: runId,
workflow_run_id: smithersRunId,
action: "resume" as const,
submitted: !result.alreadyRunning
});
// An attach to a run Smithers still reports active started no controller,
// so it must not re-record status, lease or deadline; the resume that
// starts the next controller does.
if (result.alreadyRunning !== true) {
recordNativeContinuationState({ layout, config, requestedConcurrency: input.maxConcurrency });
}
return runtimeResult(
true,
{
run_id: runId,
workflow_run_id: smithersRunId,
action: "resume" as const,
submitted: !result.alreadyRunning
},
diagnostics
);
} catch (error) {
return runtimeFailure<WorkflowLifecycleValue>([smithersDiagnostic(error, "WORKFLOW_LIFECYCLE_FAILED")]);
return runtimeFailure<WorkflowLifecycleValue>([
smithersDiagnostic(error, "WORKFLOW_LIFECYCLE_FAILED"),
...diagnostics
]);
} finally {
await releaseLifecycleLock?.();
}
Expand Down Expand Up @@ -739,7 +778,6 @@ function recordNativeContinuationState(input: {
layout: RunLayout;
config: ResolvedConfig | undefined;
requestedConcurrency: number | undefined;
alreadyRunning: boolean;
}): void {
try {
const submittedAt = new Date().toISOString();
Expand All @@ -749,19 +787,17 @@ function recordNativeContinuationState(input: {
state.status = "running";
state.started_at ??= submittedAt;
delete state.finished_at;
if (!input.alreadyRunning) {
const leaseDurationMs =
(input.config?.run.controllerLeaseSeconds ?? Math.max(1, state.controller_lease.duration_ms / 1_000)) * 1_000;
state.controller_lease = {
...state.controller_lease,
status: "active",
duration_ms: leaseDurationMs,
renewed_at: submittedAt,
expires_at: new Date(submittedAtMs + leaseDurationMs).toISOString()
};
state.concurrency.requested_concurrency =
input.requestedConcurrency ?? input.config?.run.maxParallelAgents ?? state.concurrency.requested_concurrency;
}
const leaseDurationMs =
(input.config?.run.controllerLeaseSeconds ?? Math.max(1, state.controller_lease.duration_ms / 1_000)) * 1_000;
state.controller_lease = {
...state.controller_lease,
status: "active",
duration_ms: leaseDurationMs,
renewed_at: submittedAt,
expires_at: new Date(submittedAtMs + leaseDurationMs).toISOString()
};
state.concurrency.requested_concurrency =
input.requestedConcurrency ?? input.config?.run.maxParallelAgents ?? state.concurrency.requested_concurrency;
if (input.config !== undefined) {
state.workflow_deadline_at = new Date(
submittedAtMs + input.config.run.workflowDeadlineSeconds * 1_000
Expand All @@ -779,6 +815,11 @@ function recordNativeContinuationState(input: {
}
}

function resumeWarning(code: string, context: string, error: unknown): RuntimeDiagnostic {
const diagnostic = smithersDiagnostic(error, code);
return { ...diagnostic, message: `${context}: ${diagnostic.message}`, severity: "warning", source: "runtime" };
}

export async function replayRun(input: WorkflowLifecycleInput) {
return submitLifecycleAction(input, "replay");
}
Expand Down
Loading
Loading