Refuse what a node cannot publish, and stop timing out work that is fine

Four things the python SDK turned up, each fixed where every client sees it.

A key no port declares is now an error rather than a silent drop, on the
return, the yield and the emit alike — the contract the docs already stated.
The SDK reads literal yields at sync time, so a typo fails before anything
runs, and an emission of one fails the call rather than being logged where
nobody looks.

NaN and infinity are refused at the port. JSON cannot spell either, so one
that travelled came back as a 500, a socket frame that stopped the canvas, or
a metric batch the database dropped whole.

An artifact input takes `@run:<id>.<output>` or a bare digest, resolved on the
engine — so the CLI, the run dialog and a python caller mean the same thing,
and a sweep can pass one at all.

Node timeouts are off by default. The clock measured silence, which a training
node is full of, and remote workers had already stopped enforcing it — their
heartbeat reset it. Now a heartbeat proves the agent rather than the node,
ninety seconds of nothing fails the call either way, and the engine touches
work it is still running so a long node is not redelivered at sixty seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V5bsYGNxcgPs4xXmTPx69
This commit is contained in:
2026-08-25 07:30:14 +02:00
co-authored by Claude Opus 5
parent c33fa404a4
commit 93374a310e
32 changed files with 968 additions and 67 deletions
+4 -4
View File
@@ -1606,14 +1606,14 @@ export const NodeDef_InputSchema = {
anyOf: [
{
type: 'number',
exclusiveMinimum: 0
minimum: 0
},
{
type: 'null'
}
],
title: 'Timeout',
description: "Seconds this node's code may run before it is stopped. This covers the first call's imports, which can be much slower than the body. Above 60 the engine may deliver its work again while it is still running — in a batch run, which never redelivers, it is an idle timeout instead: silence this long is a kill."
description: "Seconds this node's code may be silent before it is stopped. A yield or an emit resets the clock, and the first call's imports are not charged to it. 0 disables the limit: the node runs until it finishes, and only a dead worker fails the call. Empty inherits the engine default."
},
device: {
anyOf: [
@@ -1701,14 +1701,14 @@ export const NodeDef_OutputSchema = {
anyOf: [
{
type: 'number',
exclusiveMinimum: 0
minimum: 0
},
{
type: 'null'
}
],
title: 'Timeout',
description: "Seconds this node's code may run before it is stopped. This covers the first call's imports, which can be much slower than the body. Above 60 the engine may deliver its work again while it is still running — in a batch run, which never redelivers, it is an idle timeout instead: silence this long is a kill."
description: "Seconds this node's code may be silent before it is stopped. A yield or an emit resets the clock, and the first call's imports are not charged to it. 0 disables the limit: the node runs until it finishes, and only a dead worker fails the call. Empty inherits the engine default."
},
device: {
anyOf: [
+2 -2
View File
@@ -610,7 +610,7 @@ export type NodeDef_Input = {
provides?: Array<MessageSpec>;
source_ref?: (string | null);
/**
* Seconds this node's code may run before it is stopped. This covers the first call's imports, which can be much slower than the body. Above 60 the engine may deliver its work again while it is still running — in a batch run, which never redelivers, it is an idle timeout instead: silence this long is a kill.
* Seconds this node's code may be silent before it is stopped. A yield or an emit resets the clock, and the first call's imports are not charged to it. 0 disables the limit: the node runs until it finishes, and only a dead worker fails the call. Empty inherits the engine default.
*/
timeout?: (number | null);
/**
@@ -650,7 +650,7 @@ export type NodeDef_Output = {
provides?: Array<MessageSpec>;
source_ref?: (string | null);
/**
* Seconds this node's code may run before it is stopped. This covers the first call's imports, which can be much slower than the body. Above 60 the engine may deliver its work again while it is still running — in a batch run, which never redelivers, it is an idle timeout instead: silence this long is a kill.
* Seconds this node's code may be silent before it is stopped. A yield or an emit resets the clock, and the first call's imports are not charged to it. 0 disables the limit: the node runs until it finishes, and only a dead worker fails the call. Empty inherits the engine default.
*/
timeout?: (number | null);
/**
@@ -40,6 +40,14 @@ export function parseByDtype(dtype: DType | undefined, raw: string): unknown {
}
if (dtype === "bool") return raw === "true"
if (dtype === "str") return raw
// An artifact is named rather than typed out: "@run:<id>.<output>" or the
// digest itself, which the engine resolves into the reference.
if (
dtype === "artifact" &&
(raw.startsWith("@run:") || raw.startsWith("sha256:"))
) {
return raw
}
try {
return JSON.parse(raw)
} catch {
+9 -5
View File
@@ -1183,18 +1183,22 @@ function PanelBody({
min={0}
step="any"
className="h-8 text-sm"
placeholder="30 (default)"
value={node.timeout ? String(node.timeout) : ""}
placeholder="no limit (default)"
value={node.timeout != null ? String(node.timeout) : ""}
onChange={(event) =>
onChange({
...node,
timeout: Number(event.target.value) || null,
timeout:
event.target.value === ""
? null
: Math.max(0, Number(event.target.value) || 0),
})
}
/>
<p className="text-xs text-muted-foreground">
Seconds this code may run before it is stopped. Above 60 the
engine may deliver the same work again while it is still running.
Seconds this code may be silent before it is stopped a yield or
an emit resets the clock. 0 disables the limit; empty uses the
engine default.
</p>
</div>
) : null}
+5 -1
View File
@@ -98,7 +98,11 @@ export function RunDialog({
<Input
id={`param-${name}`}
value={asText(values[name])}
placeholder={dtype ?? "float"}
placeholder={
dtype === "artifact"
? "@run:<id>.<output> or sha256:…"
: (dtype ?? "float")
}
className="text-sm"
onChange={(event) =>
setValues({