skillscript-runtime 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +5 -4
- package/dist/bootstrap-from-env.d.ts.map +1 -1
- package/dist/bootstrap-from-env.js +2 -0
- package/dist/bootstrap-from-env.js.map +1 -1
- package/dist/bootstrap.d.ts +9 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +4 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/composition.d.ts +4 -0
- package/dist/composition.d.ts.map +1 -1
- package/dist/composition.js +12 -0
- package/dist/composition.js.map +1 -1
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +11 -0
- package/dist/connectors/registry.js.map +1 -1
- package/dist/connectors/types.d.ts +36 -0
- package/dist/connectors/types.d.ts.map +1 -1
- package/dist/connectors/types.js.map +1 -1
- package/dist/errors.d.ts +64 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +60 -0
- package/dist/errors.js.map +1 -1
- package/dist/help-content.d.ts +1 -1
- package/dist/help-content.d.ts.map +1 -1
- package/dist/help-content.js +5 -1
- package/dist/help-content.js.map +1 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +38 -0
- package/dist/lint.js.map +1 -1
- package/dist/mcp-server.d.ts +8 -0
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +8 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/parser.d.ts +11 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +17 -0
- package/dist/parser.js.map +1 -1
- package/dist/runtime-config.d.ts +8 -0
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +8 -0
- package/dist/runtime-config.js.map +1 -1
- package/dist/runtime-env-resolver.d.ts +2 -0
- package/dist/runtime-env-resolver.d.ts.map +1 -1
- package/dist/runtime-env-resolver.js +8 -0
- package/dist/runtime-env-resolver.js.map +1 -1
- package/dist/runtime.d.ts +86 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +296 -73
- package/dist/runtime.js.map +1 -1
- package/dist/scheduler.d.ts +3 -0
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +3 -0
- package/dist/scheduler.js.map +1 -1
- package/dist/trace.d.ts +22 -2
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js +7 -1
- package/dist/trace.js.map +1 -1
- package/docs/adopter-playbook.md +20 -0
- package/docs/language-reference.md +20 -1
- package/examples/skillscripts/classify-support-ticket.skill.provenance.json +1 -1
- package/examples/skillscripts/data-store-roundtrip.skill.provenance.json +1 -1
- package/examples/skillscripts/doc-qa-with-citations.skill.provenance.json +1 -1
- package/examples/skillscripts/feedback-sentiment-scan.skill.provenance.json +1 -1
- package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
- package/examples/skillscripts/morning-brief.skill.provenance.json +1 -1
- package/examples/skillscripts/queue-length-monitor.skill.provenance.json +1 -1
- package/examples/skillscripts/service-health-watch.skill.provenance.json +1 -1
- package/examples/skillscripts/skill-store-roundtrip.skill.provenance.json +1 -1
- package/examples/skillscripts/youtrack-morning-sweep.skill.provenance.json +1 -1
- package/package.json +1 -1
- package/scaffold/.env.example +9 -0
package/dist/runtime.js
CHANGED
|
@@ -6,7 +6,7 @@ import { validateQualifiedDispatch } from "./dispatch-validate.js";
|
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { readFile as fsReadFile, writeFile as fsWriteFile, mkdir as fsMkdir } from "node:fs/promises";
|
|
8
8
|
import { dirname as pathDirname } from "node:path";
|
|
9
|
-
import { OpError, ConnectorNotFoundError, OpTimeoutError, UnsafeShellDisabledError, ShellBinaryNotAllowedError, UnresolvedVariableError, TypeMismatchError, MissingSkillReferenceError, UnconfirmedMutationError, SecuredModeEffectError, FilePathNotAllowedError, messageOf, } from "./errors.js";
|
|
9
|
+
import { OpError, ConnectorNotFoundError, OpTimeoutError, RunDeadlineExceeded, UnsafeShellDisabledError, ShellBinaryNotAllowedError, UnresolvedVariableError, TypeMismatchError, MissingSkillReferenceError, UnconfirmedMutationError, SecuredModeEffectError, FilePathNotAllowedError, messageOf, } from "./errors.js";
|
|
10
10
|
import { isPathUnderAllowedRoot } from "./safe-path.js";
|
|
11
11
|
import { isSecuredMode } from "./approval.js";
|
|
12
12
|
import { classifyMutation, authorizationGranted, buildAuthorizationSuggestion, } from "./mutation-gate.js";
|
|
@@ -149,59 +149,141 @@ export async function execute(parsed, initialVars, order, ctx) {
|
|
|
149
149
|
// v0.9.6 — stash skill identity onto ctx so deep-stack op handlers
|
|
150
150
|
// (notify()) can read for DeliveryMeta without threading `parsed`
|
|
151
151
|
// through every call hop. Internal convention; do not set from outside.
|
|
152
|
-
|
|
152
|
+
// Run-total deadline (# Deadline:). Resolve ONCE at the root (`ctx.deadlineMs`
|
|
153
|
+
// undefined) into an absolute wall-clock instant; a child inherits the parent's
|
|
154
|
+
// instant via this same spread and can only TIGHTEN it with its own
|
|
155
|
+
// # Deadline: (`min`), never loosen the root bound (Perry decision #2). Opt-in:
|
|
156
|
+
// no # Deadline: and nothing inherited => undefined => today's per-op behavior.
|
|
157
|
+
let deadlineMs = ctx.deadlineMs;
|
|
158
|
+
// Budget (declared duration) + origin travel alongside the absolute instant so
|
|
159
|
+
// the RunDeadlineExceeded message reports what was set, not an epoch number.
|
|
160
|
+
// Inherited from the parent by default; overwritten only when a tighter local
|
|
161
|
+
// bound wins below (so the message names the bound that actually fired).
|
|
162
|
+
let deadlineBudgetMs = ctx.deadlineBudgetMs;
|
|
163
|
+
let deadlineSource = ctx.deadlineSource;
|
|
164
|
+
// Operator ceiling FIRST: it bounds every run, even one with no `# Deadline:`,
|
|
165
|
+
// so an untrusted author can't evade the bound by omitting it. Keeps the
|
|
166
|
+
// tighter of it and any inherited/injected deadline. (Harmless to re-apply in a
|
|
167
|
+
// child — the inherited root bound is always ≤ a fresh child ceiling, so the
|
|
168
|
+
// `<` never fires there.)
|
|
169
|
+
if (ctx.maxDeadlineMs !== undefined) {
|
|
170
|
+
const opMaxInstant = nowMs + ctx.maxDeadlineMs;
|
|
171
|
+
if (deadlineMs === undefined || opMaxInstant < deadlineMs) {
|
|
172
|
+
deadlineMs = opMaxInstant;
|
|
173
|
+
deadlineBudgetMs = ctx.maxDeadlineMs;
|
|
174
|
+
deadlineSource = "operator ceiling";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// A skill's own `# Deadline:` can only TIGHTEN — never loosen past the operator
|
|
178
|
+
// ceiling or the inherited parent bound.
|
|
179
|
+
if (parsed.deadline !== null) {
|
|
180
|
+
const budgetMs = resolveIntParam(parsed.deadline, vars, "# Deadline:") * 1000;
|
|
181
|
+
const candidate = nowMs + budgetMs;
|
|
182
|
+
if (deadlineMs === undefined || candidate < deadlineMs) {
|
|
183
|
+
deadlineMs = candidate;
|
|
184
|
+
deadlineBudgetMs = budgetMs;
|
|
185
|
+
deadlineSource = "# Deadline";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
ctx = { ...ctx, _currentSkillName: skillName, _currentSkillEventType: parsed.eventType, _currentSkillSecretRequires: parsed.secretRequires, _insideExecute: true, ...(deadlineMs !== undefined ? { deadlineMs } : {}), ...(deadlineBudgetMs !== undefined ? { deadlineBudgetMs } : {}), ...(deadlineSource !== undefined ? { deadlineSource } : {}) };
|
|
153
189
|
const traceBuilder = shouldTraceFire(ctx.trace, triggerId, skillName)
|
|
154
190
|
? new TraceBuilder(skillName, ctx.skillVersion ?? "unknown", triggerCtx, { agent_id: ctx.agentId }, ctx.preMintedTraceId)
|
|
155
191
|
: null;
|
|
156
|
-
for
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
targetLastValue = r.lastBoundVar !== null ? vars.get(r.lastBoundVar) : r.lastValue;
|
|
180
|
-
}
|
|
181
|
-
catch (innerErr) {
|
|
182
|
-
errors.push(buildExecutionError(innerErr, targetName, "else"));
|
|
183
|
-
}
|
|
192
|
+
// Run boundary for the deadline. `RunDeadlineExceeded` is uncatchable by any
|
|
193
|
+
// op/target machinery (each catch re-throws it); it terminates the target loop
|
|
194
|
+
// here and the run returns whatever it accumulated so far + the effect log —
|
|
195
|
+
// it is NEVER thrown to the caller. `deadlineExceeded` marks the outcome.
|
|
196
|
+
let deadlineExceeded = false;
|
|
197
|
+
const uncertainEffects = [];
|
|
198
|
+
try {
|
|
199
|
+
for (const targetName of order) {
|
|
200
|
+
const target = parsed.targets.get(targetName);
|
|
201
|
+
if (!target)
|
|
202
|
+
continue;
|
|
203
|
+
let targetLastBound = null;
|
|
204
|
+
let targetLastValue = undefined;
|
|
205
|
+
// Fresh mutation-auth state per target. `skillAutonomous` is set from
|
|
206
|
+
// the parsed header. v0.16.0 retired the `sawConfirm` path with the
|
|
207
|
+
// `ask` op removal.
|
|
208
|
+
const authState = {
|
|
209
|
+
skillAutonomous: parsed.autonomous === true,
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
const r = await execOps(target.ops, vars, emissions, fallbacks, ctx, targetName, parsed.timeout, absoluteTimeoutMs, traceBuilder, authState);
|
|
213
|
+
targetLastBound = r.lastBoundVar;
|
|
214
|
+
targetLastValue = r.lastBoundVar !== null ? vars.get(r.lastBoundVar) : r.lastValue;
|
|
184
215
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
216
|
+
catch (err) {
|
|
217
|
+
// Uncatchable-sweep: the run deadline is run-terminal — it must NOT be
|
|
218
|
+
// recovered by a target `else:`. Re-throw so it reaches the run boundary.
|
|
219
|
+
if (err instanceof RunDeadlineExceeded)
|
|
220
|
+
throw err;
|
|
221
|
+
errors.push(buildExecutionError(err, targetName));
|
|
222
|
+
if (target.elseBlock !== undefined) {
|
|
223
|
+
try {
|
|
224
|
+
const r = await execOps(target.elseBlock, vars, emissions, fallbacks, ctx, targetName, parsed.timeout, absoluteTimeoutMs, traceBuilder, authState);
|
|
225
|
+
targetLastBound = r.lastBoundVar;
|
|
226
|
+
targetLastValue = r.lastBoundVar !== null ? vars.get(r.lastBoundVar) : r.lastValue;
|
|
227
|
+
}
|
|
228
|
+
catch (innerErr) {
|
|
229
|
+
if (innerErr instanceof RunDeadlineExceeded)
|
|
230
|
+
throw innerErr;
|
|
231
|
+
errors.push(buildExecutionError(innerErr, targetName, "else"));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (parsed.onError !== null && ctx.fallbackSkillExecutor) {
|
|
235
|
+
try {
|
|
236
|
+
const fbResult = await ctx.fallbackSkillExecutor(parsed.onError, Object.fromEntries(vars));
|
|
237
|
+
for (const em of fbResult.emissions)
|
|
238
|
+
emissions.push(em);
|
|
239
|
+
for (const fe of fbResult.errors)
|
|
240
|
+
errors.push(fe);
|
|
241
|
+
}
|
|
242
|
+
catch (fbErr) {
|
|
243
|
+
errors.push(buildExecutionError(fbErr, parsed.onError, "skill-fallback"));
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
192
246
|
}
|
|
193
|
-
|
|
194
|
-
|
|
247
|
+
else {
|
|
248
|
+
break;
|
|
195
249
|
}
|
|
196
|
-
break;
|
|
197
250
|
}
|
|
198
|
-
|
|
199
|
-
|
|
251
|
+
vars.set(`${targetName}.output`, targetLastValue);
|
|
252
|
+
if (targetLastBound !== null)
|
|
253
|
+
lastBoundVar = targetLastBound;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
// Only the run deadline reaches here (targets catch their own op errors).
|
|
258
|
+
if (err instanceof RunDeadlineExceeded) {
|
|
259
|
+
// In a composition CHILD, PROPAGATE so the whole tree aborts as one unit —
|
|
260
|
+
// the parent's execute_skill intercept re-throws it up to the ROOT, which
|
|
261
|
+
// alone catches it and returns the partial result. This is why a deep gather
|
|
262
|
+
// can't outlive the root's deadline: the shared instant fires once and
|
|
263
|
+
// unwinds the entire stack. A run entered via the composition wrapper
|
|
264
|
+
// (MCP/scheduler) has recursionDepth 1 but IS the root (`_runRoot`) — it must
|
|
265
|
+
// CONVERT here, not re-throw, else the deadline escaped into mcp-server's
|
|
266
|
+
// catch-all and dropped deadline_exceeded/uncertain_effects (finding C).
|
|
267
|
+
if ((ctx.recursionDepth ?? 0) > 0 && ctx._runRoot !== true)
|
|
268
|
+
throw err;
|
|
269
|
+
deadlineExceeded = true;
|
|
270
|
+
errors.push(buildExecutionError(err, err.target ?? "run"));
|
|
271
|
+
// An external dispatch cut mid-flight has an unknown outcome — record it
|
|
272
|
+
// (only provably-non-effecting local ops are excluded, cutOp.uncertainWhenCut
|
|
273
|
+
// false for them). Never auto-retried.
|
|
274
|
+
if (err.cutOp?.uncertainWhenCut === true) {
|
|
275
|
+
uncertainEffects.push({
|
|
276
|
+
target: err.target,
|
|
277
|
+
opKind: err.cutOp.opKind,
|
|
278
|
+
op: err.cutOp.label,
|
|
279
|
+
reason: "issued, outcome uncertain",
|
|
280
|
+
retry: false,
|
|
281
|
+
});
|
|
200
282
|
}
|
|
201
283
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
284
|
+
else {
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
205
287
|
}
|
|
206
288
|
// Outputs map per `# Output:` declarations. Per-kind value semantics:
|
|
207
289
|
// - Agent-bound surfaces (`agent:`, `template:`): default to joined
|
|
@@ -285,7 +367,9 @@ export async function execute(parsed, initialVars, order, ctx) {
|
|
|
285
367
|
// capability as the op-level choke. In secured mode an unapproved body cannot
|
|
286
368
|
// deliver to an agent/template, even though its emit-to-transcript ran.
|
|
287
369
|
const deliveryAuthorized = !(isSecuredMode() && ctx.effectsAuthorized !== true);
|
|
288
|
-
|
|
370
|
+
// Past the deadline is past the deadline: a terminated run does NOT then
|
|
371
|
+
// dispatch its `# Output: agent:` delivery (that's an effect past the bound).
|
|
372
|
+
if (ctx.mechanical !== true && deliveryAuthorized && !deadlineExceeded) {
|
|
289
373
|
for (const decl of outputDecls) {
|
|
290
374
|
if (decl.target === undefined)
|
|
291
375
|
continue;
|
|
@@ -347,7 +431,7 @@ export async function execute(parsed, initialVars, order, ctx) {
|
|
|
347
431
|
// up as op errors; the trace store is an observability surface, not a
|
|
348
432
|
// dispatch dependency).
|
|
349
433
|
if (traceBuilder !== null && ctx.traceStore !== undefined) {
|
|
350
|
-
const record = traceBuilder.finalize(emissions, outputs, errors);
|
|
434
|
+
const record = traceBuilder.finalize(emissions, outputs, errors, { deadlineExceeded, uncertainEffects });
|
|
351
435
|
try {
|
|
352
436
|
await ctx.traceStore.write(record);
|
|
353
437
|
}
|
|
@@ -375,6 +459,8 @@ export async function execute(parsed, initialVars, order, ctx) {
|
|
|
375
459
|
targetOrder: order,
|
|
376
460
|
agentDeliveryReceipts,
|
|
377
461
|
agentWakeReceipts,
|
|
462
|
+
...(deadlineExceeded ? { deadlineExceeded: true } : {}),
|
|
463
|
+
...(uncertainEffects.length > 0 ? { uncertainEffects } : {}),
|
|
378
464
|
};
|
|
379
465
|
}
|
|
380
466
|
async function execOps(ops, vars, emissions, fallbacks, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder, authState) {
|
|
@@ -627,7 +713,7 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
627
713
|
// Closes Perry's `adc87d52` cold-author-safety finding.
|
|
628
714
|
if (op.argv !== undefined) {
|
|
629
715
|
const substArgv = op.argv.map((el) => substituteRuntime(el, vars));
|
|
630
|
-
const shellTimeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars);
|
|
716
|
+
const shellTimeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars, ctx.deadlineMs, targetName, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
631
717
|
if (ctx.mechanical === true) {
|
|
632
718
|
const preview = substArgv.join(" ");
|
|
633
719
|
emissions.push(`Would run shell argv: ${preview} (mechanical: true preview).`);
|
|
@@ -658,9 +744,13 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
658
744
|
}
|
|
659
745
|
const finalArgv = await Promise.all(realArgv.map((el, i) => spliceSecretsAtSink(el, maskedArgv[i].names)));
|
|
660
746
|
const [bin, ...args] = finalArgv;
|
|
661
|
-
stdoutArgv = await execShellCommand(bin, args, shellTimeoutMs);
|
|
747
|
+
stdoutArgv = await execShellCommand(bin, args, shellTimeoutMs, ctx.deadlineMs, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
662
748
|
}
|
|
663
749
|
catch (err) {
|
|
750
|
+
if (err instanceof RunDeadlineExceeded) {
|
|
751
|
+
err.cutOp ??= { opKind: "shell", label: "shell", uncertainWhenCut: true };
|
|
752
|
+
throw err;
|
|
753
|
+
}
|
|
664
754
|
if (shellFallback !== undefined) {
|
|
665
755
|
return recordShellFallback(shellFallback, redactSecrets(`shell argv failed: ${messageOf(err)}`));
|
|
666
756
|
}
|
|
@@ -682,7 +772,7 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
682
772
|
const body = op.policy === "unsafe"
|
|
683
773
|
? substituteRuntimeUnsafe(op.body, vars)
|
|
684
774
|
: substituteRuntime(op.body, vars);
|
|
685
|
-
const shellTimeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars);
|
|
775
|
+
const shellTimeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars, ctx.deadlineMs, targetName, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
686
776
|
if (ctx.mechanical === true) {
|
|
687
777
|
const label = op.policy === "unsafe" ? "Would run unsafe shell" : "Would run shell";
|
|
688
778
|
emissions.push(`${label}: ${body} (mechanical: true preview).`);
|
|
@@ -726,7 +816,7 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
726
816
|
// argv/`ps` leak window; vault-era sink-aware injection closes it).
|
|
727
817
|
// Unsafe shell is operator-gated, so a secret here is deliberate.
|
|
728
818
|
const finalBody = await spliceSecretsAtSink(realBody, names);
|
|
729
|
-
stdout = await execShellCommand("bash", ["-c", finalBody], shellTimeoutMs);
|
|
819
|
+
stdout = await execShellCommand("bash", ["-c", finalBody], shellTimeoutMs, ctx.deadlineMs, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
730
820
|
}
|
|
731
821
|
else {
|
|
732
822
|
const tokens = tokenizeShellArgs(realBody);
|
|
@@ -745,10 +835,14 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
745
835
|
// spaces/quotes stays one argv element and never reaches a shell.
|
|
746
836
|
const finalTokens = await Promise.all(tokens.map((t) => spliceSecretsAtSink(t, names)));
|
|
747
837
|
const [bin, ...args] = finalTokens;
|
|
748
|
-
stdout = await execShellCommand(bin, args, shellTimeoutMs);
|
|
838
|
+
stdout = await execShellCommand(bin, args, shellTimeoutMs, ctx.deadlineMs, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
749
839
|
}
|
|
750
840
|
}
|
|
751
841
|
catch (err) {
|
|
842
|
+
if (err instanceof RunDeadlineExceeded) {
|
|
843
|
+
err.cutOp ??= { opKind: "shell", label: "shell", uncertainWhenCut: true };
|
|
844
|
+
throw err;
|
|
845
|
+
}
|
|
752
846
|
if (shellFallback !== undefined) {
|
|
753
847
|
return recordShellFallback(shellFallback, redactSecrets(`shell failed: ${messageOf(err)}`));
|
|
754
848
|
}
|
|
@@ -1041,6 +1135,11 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
1041
1135
|
return { lastBoundVar: op.outputVar ?? flatKey, lastValue: childResult };
|
|
1042
1136
|
}
|
|
1043
1137
|
catch (err) {
|
|
1138
|
+
// Uncatchable-sweep: a child that hit the (shared) run deadline
|
|
1139
|
+
// propagates `RunDeadlineExceeded` up — the whole tree aborts; this
|
|
1140
|
+
// intercept must NOT convert it to the parent's `(fallback:)`.
|
|
1141
|
+
if (err instanceof RunDeadlineExceeded)
|
|
1142
|
+
throw err;
|
|
1044
1143
|
// v0.27.0 — `(fallback:)` now contains a RAISED THROW from the
|
|
1045
1144
|
// execute_skill intercept (a child whose execute() escapes a throw —
|
|
1046
1145
|
// e.g. its output template references an unset var), not just a
|
|
@@ -1084,6 +1183,11 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
1084
1183
|
parsed = JSON.parse(input);
|
|
1085
1184
|
}
|
|
1086
1185
|
catch (err) {
|
|
1186
|
+
// Uncatchable-sweep invariant (defensive — json_parse is local/instant
|
|
1187
|
+
// so it can't originate a deadline, but every op-catch re-throws fatal
|
|
1188
|
+
// for the structural guard).
|
|
1189
|
+
if (err instanceof RunDeadlineExceeded)
|
|
1190
|
+
throw err;
|
|
1087
1191
|
// v0.27.0 — `(fallback:)` contains a json_parse off-shape throw too
|
|
1088
1192
|
// (uniform trailer semantics; see the execute_skill intercept above).
|
|
1089
1193
|
// The parse-error message is kept in `fallbacks[].reason`.
|
|
@@ -1148,7 +1252,7 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
1148
1252
|
}
|
|
1149
1253
|
let rawResult;
|
|
1150
1254
|
let dispatched = false;
|
|
1151
|
-
const timeoutMs = resolveOpTimeoutMs(perOpTimeoutSec, skillTimeoutSec, absoluteTimeoutMs, vars);
|
|
1255
|
+
const timeoutMs = resolveOpTimeoutMs(perOpTimeoutSec, skillTimeoutSec, absoluteTimeoutMs, vars, ctx.deadlineMs, targetName, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
1152
1256
|
// Op-level fallback (per language reference §9, extended to `$` for
|
|
1153
1257
|
// cold-agent corpus consistency). On dispatch throw, bind the
|
|
1154
1258
|
// fallback value to the output var; on missing connector with
|
|
@@ -1172,11 +1276,17 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
1172
1276
|
}
|
|
1173
1277
|
if (ctx.registry.hasMcpConnector(connectorName)) {
|
|
1174
1278
|
const connector = ctx.registry.getMcpConnector(connectorName);
|
|
1175
|
-
|
|
1279
|
+
// Pass the connector's bounded cleanup hook so the deadline can invoke
|
|
1280
|
+
// it on an in-flight cut (outlives-call connectors only; call-bounded
|
|
1281
|
+
// ones have no onAbort → reserve 0, zero tax).
|
|
1282
|
+
const onAbort = typeof connector.onAbort === "function"
|
|
1283
|
+
? (budgetMs) => connector.onAbort(budgetMs)
|
|
1284
|
+
: undefined;
|
|
1285
|
+
rawResult = await dispatchWithTimeout((signal) => connector.call(toolName, dispatchArgs, { ...(ctx.agentId !== undefined ? { agentId: ctx.agentId } : {}), signal }), timeoutMs, "$", ctx.deadlineMs, onAbort, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
1176
1286
|
dispatched = true;
|
|
1177
1287
|
}
|
|
1178
1288
|
else if (op.mcpConnector === undefined && ctx.toolDispatch) {
|
|
1179
|
-
rawResult = await dispatchWithTimeout(() => ctx.toolDispatch(toolName, dispatchArgs), timeoutMs, "$");
|
|
1289
|
+
rawResult = await dispatchWithTimeout(() => ctx.toolDispatch(toolName, dispatchArgs), timeoutMs, "$", ctx.deadlineMs, undefined, ctx.deadlineBudgetMs, ctx.deadlineSource);
|
|
1180
1290
|
dispatched = true;
|
|
1181
1291
|
}
|
|
1182
1292
|
else {
|
|
@@ -1193,6 +1303,13 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
|
|
|
1193
1303
|
}
|
|
1194
1304
|
}
|
|
1195
1305
|
catch (err) {
|
|
1306
|
+
// Uncatchable-sweep: a mid-flight run-deadline expiry must NOT be
|
|
1307
|
+
// recovered by this op's `(fallback:)` — tag the cut op (for the
|
|
1308
|
+
// uncertain-effect log; deepest op wins via `??=`) and re-throw.
|
|
1309
|
+
if (err instanceof RunDeadlineExceeded) {
|
|
1310
|
+
err.cutOp ??= { opKind: "$", label: `${connectorLabel}${toolName}`, uncertainWhenCut: dispatchUncertainWhenCut(toolName) };
|
|
1311
|
+
throw err;
|
|
1312
|
+
}
|
|
1196
1313
|
if (dollarFallback !== undefined) {
|
|
1197
1314
|
vars.set(flatKey, dollarFallback);
|
|
1198
1315
|
if (op.outputVar !== undefined)
|
|
@@ -1324,6 +1441,17 @@ function buildExecutionError(err, target, opKindOverride) {
|
|
|
1324
1441
|
};
|
|
1325
1442
|
}
|
|
1326
1443
|
const DEFAULT_RUNTIME_ABSOLUTE_TIMEOUT_MS = 300_000;
|
|
1444
|
+
/**
|
|
1445
|
+
* Cleanup slice reserved from the run budget for an `"outlives-call"` connector's
|
|
1446
|
+
* `onAbort()` when the deadline cuts an in-flight call (Perry spec de11dcc5).
|
|
1447
|
+
* Sized to realistic bounded cleanup (`stop_all_movements` / socket-close /
|
|
1448
|
+
* SIGKILL are sub-second). Reserved PER-OP and only for outlives-call ops
|
|
1449
|
+
* (`onAbort` present), so the call-bounded majority pays zero tax; floored
|
|
1450
|
+
* against remaining-at-dispatch (`min(CLEANUP_CAP, remaining)`) so `effective`
|
|
1451
|
+
* never goes negative and a late outlives-call op fails fast rather than starting
|
|
1452
|
+
* an effect it can't clean in time. Keeps total wall-clock ≤ the deadline.
|
|
1453
|
+
*/
|
|
1454
|
+
const CLEANUP_CAP_MS = 1_000;
|
|
1327
1455
|
/**
|
|
1328
1456
|
* Per-op timeout resolution chain (ERD §6 decision 7) — top wins:
|
|
1329
1457
|
* 1. Per-op override (`~ ... timeoutSeconds=30 ...`)
|
|
@@ -1335,33 +1463,121 @@ const DEFAULT_RUNTIME_ABSOLUTE_TIMEOUT_MS = 300_000;
|
|
|
1335
1463
|
* Both per-op and skill-level values are in seconds (per author convention)
|
|
1336
1464
|
* and converted to milliseconds here.
|
|
1337
1465
|
*/
|
|
1338
|
-
|
|
1466
|
+
/**
|
|
1467
|
+
* Bare `$` builtins the runtime can PROVABLY rule out an external effect for — a
|
|
1468
|
+
* read, a pure parse, composition (whose children record their own cut effects),
|
|
1469
|
+
* and a model completion (`$ llm` is prompt→text; a cut one is wasted compute,
|
|
1470
|
+
* not a landed effect). A cut of any of these leaves no uncertain external state,
|
|
1471
|
+
* so recording it would be a guaranteed false positive that pollutes the signal.
|
|
1472
|
+
* (Per Perry's ruling — the boundary is "provably no external effect," which is
|
|
1473
|
+
* why `$ llm` is excluded but a read-NAMED connector tool like `get_status` is
|
|
1474
|
+
* NOT: a connector's effect can't be ruled out from its name; that downgrade is
|
|
1475
|
+
* the explicit per-tool `effect_class` annotation, Phase 2.)
|
|
1476
|
+
*/
|
|
1477
|
+
const DISPATCH_NON_EFFECTING = new Set(["data_read", "json_parse", "execute_skill", "llm"]);
|
|
1478
|
+
/**
|
|
1479
|
+
* Should a `$` dispatch cut mid-flight be logged as "issued, outcome uncertain"?
|
|
1480
|
+
* SAFE DEFAULT: yes — every `$ connector.tool` and `data_write` may have effected
|
|
1481
|
+
* external state we can't confirm once aborted. Only ops the runtime can PROVABLY
|
|
1482
|
+
* rule out an external effect for (the set above) are excluded. Deliberately NOT
|
|
1483
|
+
* `classifyMutation` (a mutating-NAME heuristic): it misses `send_message` and
|
|
1484
|
+
* every un-verbed connector tool — exactly the production mutations — so keying
|
|
1485
|
+
* the uncertain-log on it silently dropped the highest-risk ops (adopter finding
|
|
1486
|
+
* D). Under-recording a cut mutation is dangerous; over-recording is just noise.
|
|
1487
|
+
*/
|
|
1488
|
+
export function dispatchUncertainWhenCut(toolName) {
|
|
1489
|
+
return !DISPATCH_NON_EFFECTING.has(toolName);
|
|
1490
|
+
}
|
|
1491
|
+
function resolveOpTimeoutMs(perOpTimeoutSec, skillTimeoutSec, absoluteTimeoutMs, vars, deadlineMs, targetName, deadlineBudgetMs, deadlineSource) {
|
|
1492
|
+
let base;
|
|
1339
1493
|
if (perOpTimeoutSec !== undefined) {
|
|
1340
|
-
|
|
1494
|
+
base = resolveIntParam(perOpTimeoutSec, vars, "timeoutSeconds") * 1000;
|
|
1495
|
+
}
|
|
1496
|
+
else if (skillTimeoutSec !== null) {
|
|
1497
|
+
base = resolveIntParam(skillTimeoutSec, vars, "# Timeout:") * 1000;
|
|
1341
1498
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1499
|
+
else {
|
|
1500
|
+
base = absoluteTimeoutMs;
|
|
1344
1501
|
}
|
|
1345
|
-
|
|
1502
|
+
// Run-total deadline (# Deadline:). This is the PRE-DISPATCH fail-fast + clamp,
|
|
1503
|
+
// and it gates EVERY dispatch path (called for `$` and `shell` alike, in the
|
|
1504
|
+
// main body AND inside `(fallback:)`/`else:` bodies). If the deadline has
|
|
1505
|
+
// already passed, throw the uncatchable `RunDeadlineExceeded` before the op
|
|
1506
|
+
// runs — this is the backstop that makes a *missed* catch-site swallow bounded
|
|
1507
|
+
// to "≤1 more op then re-thrown here" rather than unbounded. Otherwise clamp
|
|
1508
|
+
// the op's own timeout down to what's left of the run.
|
|
1509
|
+
if (deadlineMs !== undefined) {
|
|
1510
|
+
const remaining = deadlineMs - Date.now();
|
|
1511
|
+
if (remaining <= 0)
|
|
1512
|
+
throw new RunDeadlineExceeded(deadlineMs, targetName, deadlineBudgetMs, deadlineSource);
|
|
1513
|
+
return Math.min(base, remaining);
|
|
1514
|
+
}
|
|
1515
|
+
return base;
|
|
1346
1516
|
}
|
|
1347
1517
|
/**
|
|
1348
|
-
* Race the op against a timer. On timeout, throws `OpTimeoutError
|
|
1349
|
-
*
|
|
1518
|
+
* Race the op against a timer. On a per-op timeout, throws `OpTimeoutError`
|
|
1519
|
+
* (catchable by `(fallback:)`/`else:`). On the run deadline, throws the
|
|
1520
|
+
* uncatchable `RunDeadlineExceeded`.
|
|
1350
1521
|
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1522
|
+
* Cancellation (Perry spec de11dcc5): the timer IS the cut. For an
|
|
1523
|
+
* `"outlives-call"` connector (an `onAbort` was passed), the op is cut a
|
|
1524
|
+
* reserved slice EARLY — `reserve = min(CLEANUP_CAP, remaining)` — and
|
|
1525
|
+
* `onAbort(reserve)` runs (bounded, can't hang) before the deadline rejection,
|
|
1526
|
+
* so its cleanup fits INSIDE the run budget (total wall-clock ≤ deadline). A
|
|
1527
|
+
* late-dispatched outlives-call op gets `effective ≈ 0` → it fails fast rather
|
|
1528
|
+
* than starting an effect it can't clean in time. Call-bounded ops pass no
|
|
1529
|
+
* `onAbort`, reserve 0, and pay zero tax.
|
|
1355
1530
|
*/
|
|
1356
|
-
async function dispatchWithTimeout(fn, timeoutMs, opKind) {
|
|
1531
|
+
async function dispatchWithTimeout(fn, timeoutMs, opKind, deadlineMs, onAbort, deadlineBudgetMs, deadlineSource) {
|
|
1532
|
+
// Cancellation signal: aborted when the timer cuts this op (deadline OR per-op
|
|
1533
|
+
// timeout). A call-bounded connector that forwards it to its fetch/RPC truly
|
|
1534
|
+
// stops the work instead of race-and-abandon; one that ignores it degrades to
|
|
1535
|
+
// today's behavior.
|
|
1536
|
+
const controller = new AbortController();
|
|
1537
|
+
// Reserve a cleanup slice ONLY for outlives-call ops (onAbort present) — the
|
|
1538
|
+
// majority pays nothing. Floored against remaining-at-dispatch so it never
|
|
1539
|
+
// goes negative (Perry refinement). `cutInstant` is when THIS op must be cut
|
|
1540
|
+
// to leave room for its own cleanup; call-bounded ops cut exactly at the
|
|
1541
|
+
// deadline.
|
|
1542
|
+
const remainingAtDispatch = deadlineMs !== undefined ? Math.max(0, deadlineMs - Date.now()) : 0;
|
|
1543
|
+
const reserveMs = onAbort !== undefined && deadlineMs !== undefined
|
|
1544
|
+
? Math.min(CLEANUP_CAP_MS, remainingAtDispatch)
|
|
1545
|
+
: 0;
|
|
1546
|
+
const cutInstant = deadlineMs !== undefined ? deadlineMs - reserveMs : undefined;
|
|
1547
|
+
const effectiveMs = cutInstant !== undefined
|
|
1548
|
+
? Math.min(timeoutMs, Math.max(0, cutInstant - Date.now()))
|
|
1549
|
+
: timeoutMs;
|
|
1357
1550
|
let timer;
|
|
1358
1551
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1359
|
-
timer = setTimeout(() => {
|
|
1360
|
-
|
|
1361
|
-
|
|
1552
|
+
timer = setTimeout(async () => {
|
|
1553
|
+
// Signal the connector to stop its in-flight work first (call-bounded
|
|
1554
|
+
// true-cancel), then decide the error class + run any outlives-call cleanup.
|
|
1555
|
+
controller.abort();
|
|
1556
|
+
// If we're at/past this op's cut instant, the timer IS the run deadline
|
|
1557
|
+
// (the clamp made `effectiveMs` land there); otherwise it's a per-op
|
|
1558
|
+
// timeout (`OpTimeoutError`, catchable).
|
|
1559
|
+
if (cutInstant !== undefined && Date.now() >= cutInstant) {
|
|
1560
|
+
if (onAbort !== undefined && reserveMs > 0) {
|
|
1561
|
+
// Bounded cleanup: give onAbort up to `reserveMs`, never let it hang or
|
|
1562
|
+
// throw past the budget. (A `< CLEANUP_CAP` window for a late op is the
|
|
1563
|
+
// documented "cleanup incomplete" case.)
|
|
1564
|
+
await Promise.race([
|
|
1565
|
+
(async () => { try {
|
|
1566
|
+
await onAbort(reserveMs);
|
|
1567
|
+
}
|
|
1568
|
+
catch { /* connector cleanup best-effort */ } })(),
|
|
1569
|
+
new Promise((res) => setTimeout(res, reserveMs)),
|
|
1570
|
+
]);
|
|
1571
|
+
}
|
|
1572
|
+
reject(new RunDeadlineExceeded(deadlineMs, undefined, deadlineBudgetMs, deadlineSource));
|
|
1573
|
+
}
|
|
1574
|
+
else {
|
|
1575
|
+
reject(new OpTimeoutError(timeoutMs, opKind));
|
|
1576
|
+
}
|
|
1577
|
+
}, effectiveMs);
|
|
1362
1578
|
});
|
|
1363
1579
|
try {
|
|
1364
|
-
return await Promise.race([fn(), timeoutPromise]);
|
|
1580
|
+
return await Promise.race([fn(controller.signal), timeoutPromise]);
|
|
1365
1581
|
}
|
|
1366
1582
|
finally {
|
|
1367
1583
|
if (timer !== undefined)
|
|
@@ -1442,7 +1658,7 @@ function scrubbedShellEnv() {
|
|
|
1442
1658
|
* spawn both the structured `command=`/`argv=` and the `unsafe=true` (`bash
|
|
1443
1659
|
* -c`) paths funnel through, so the scrub covers every shell egress.
|
|
1444
1660
|
*/
|
|
1445
|
-
async function execShellCommand(bin, args, timeoutMs) {
|
|
1661
|
+
async function execShellCommand(bin, args, timeoutMs, deadlineMs, deadlineBudgetMs, deadlineSource) {
|
|
1446
1662
|
return new Promise((resolve, reject) => {
|
|
1447
1663
|
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], env: scrubbedShellEnv() });
|
|
1448
1664
|
let stdout = "";
|
|
@@ -1474,7 +1690,14 @@ async function execShellCommand(bin, args, timeoutMs) {
|
|
|
1474
1690
|
child.on("close", (code) => {
|
|
1475
1691
|
clearTimeout(timer);
|
|
1476
1692
|
if (killed) {
|
|
1477
|
-
|
|
1693
|
+
// The SIGKILL above IS real cancellation (the process group is dead —
|
|
1694
|
+
// shell is the day-one `honors` cancellation exemplar). When the kill
|
|
1695
|
+
// was the run deadline (timer clamped to remaining, now at/past it),
|
|
1696
|
+
// reject the uncatchable RunDeadlineExceeded so the shell `(fallback:)`
|
|
1697
|
+
// can't swallow it; otherwise it's a per-op timeout.
|
|
1698
|
+
reject(deadlineMs !== undefined && Date.now() >= deadlineMs
|
|
1699
|
+
? new RunDeadlineExceeded(deadlineMs, undefined, deadlineBudgetMs, deadlineSource)
|
|
1700
|
+
: new OpTimeoutError(timeoutMs, "shell"));
|
|
1478
1701
|
return;
|
|
1479
1702
|
}
|
|
1480
1703
|
if (code !== 0) {
|