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.
Files changed (74) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -4
  3. package/dist/bootstrap-from-env.d.ts.map +1 -1
  4. package/dist/bootstrap-from-env.js +2 -0
  5. package/dist/bootstrap-from-env.js.map +1 -1
  6. package/dist/bootstrap.d.ts +9 -0
  7. package/dist/bootstrap.d.ts.map +1 -1
  8. package/dist/bootstrap.js +4 -0
  9. package/dist/bootstrap.js.map +1 -1
  10. package/dist/cli.js +6 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/composition.d.ts +4 -0
  13. package/dist/composition.d.ts.map +1 -1
  14. package/dist/composition.js +12 -0
  15. package/dist/composition.js.map +1 -1
  16. package/dist/connectors/registry.d.ts.map +1 -1
  17. package/dist/connectors/registry.js +11 -0
  18. package/dist/connectors/registry.js.map +1 -1
  19. package/dist/connectors/types.d.ts +36 -0
  20. package/dist/connectors/types.d.ts.map +1 -1
  21. package/dist/connectors/types.js.map +1 -1
  22. package/dist/errors.d.ts +64 -0
  23. package/dist/errors.d.ts.map +1 -1
  24. package/dist/errors.js +60 -0
  25. package/dist/errors.js.map +1 -1
  26. package/dist/help-content.d.ts +1 -1
  27. package/dist/help-content.d.ts.map +1 -1
  28. package/dist/help-content.js +5 -1
  29. package/dist/help-content.js.map +1 -1
  30. package/dist/lint.d.ts.map +1 -1
  31. package/dist/lint.js +38 -0
  32. package/dist/lint.js.map +1 -1
  33. package/dist/mcp-server.d.ts +8 -0
  34. package/dist/mcp-server.d.ts.map +1 -1
  35. package/dist/mcp-server.js +8 -0
  36. package/dist/mcp-server.js.map +1 -1
  37. package/dist/parser.d.ts +11 -0
  38. package/dist/parser.d.ts.map +1 -1
  39. package/dist/parser.js +17 -0
  40. package/dist/parser.js.map +1 -1
  41. package/dist/runtime-config.d.ts +8 -0
  42. package/dist/runtime-config.d.ts.map +1 -1
  43. package/dist/runtime-config.js +8 -0
  44. package/dist/runtime-config.js.map +1 -1
  45. package/dist/runtime-env-resolver.d.ts +2 -0
  46. package/dist/runtime-env-resolver.d.ts.map +1 -1
  47. package/dist/runtime-env-resolver.js +8 -0
  48. package/dist/runtime-env-resolver.js.map +1 -1
  49. package/dist/runtime.d.ts +86 -0
  50. package/dist/runtime.d.ts.map +1 -1
  51. package/dist/runtime.js +296 -73
  52. package/dist/runtime.js.map +1 -1
  53. package/dist/scheduler.d.ts +3 -0
  54. package/dist/scheduler.d.ts.map +1 -1
  55. package/dist/scheduler.js +3 -0
  56. package/dist/scheduler.js.map +1 -1
  57. package/dist/trace.d.ts +22 -2
  58. package/dist/trace.d.ts.map +1 -1
  59. package/dist/trace.js +7 -1
  60. package/dist/trace.js.map +1 -1
  61. package/docs/adopter-playbook.md +20 -0
  62. package/docs/language-reference.md +20 -1
  63. package/examples/skillscripts/classify-support-ticket.skill.provenance.json +1 -1
  64. package/examples/skillscripts/data-store-roundtrip.skill.provenance.json +1 -1
  65. package/examples/skillscripts/doc-qa-with-citations.skill.provenance.json +1 -1
  66. package/examples/skillscripts/feedback-sentiment-scan.skill.provenance.json +1 -1
  67. package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
  68. package/examples/skillscripts/morning-brief.skill.provenance.json +1 -1
  69. package/examples/skillscripts/queue-length-monitor.skill.provenance.json +1 -1
  70. package/examples/skillscripts/service-health-watch.skill.provenance.json +1 -1
  71. package/examples/skillscripts/skill-store-roundtrip.skill.provenance.json +1 -1
  72. package/examples/skillscripts/youtrack-morning-sweep.skill.provenance.json +1 -1
  73. package/package.json +1 -1
  74. 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
- ctx = { ...ctx, _currentSkillName: skillName, _currentSkillEventType: parsed.eventType, _currentSkillSecretRequires: parsed.secretRequires };
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 (const targetName of order) {
157
- const target = parsed.targets.get(targetName);
158
- if (!target)
159
- continue;
160
- let targetLastBound = null;
161
- let targetLastValue = undefined;
162
- // Fresh mutation-auth state per target. `skillAutonomous` is set from
163
- // the parsed header. v0.16.0 retired the `sawConfirm` path with the
164
- // `ask` op removal.
165
- const authState = {
166
- skillAutonomous: parsed.autonomous === true,
167
- };
168
- try {
169
- const r = await execOps(target.ops, vars, emissions, fallbacks, ctx, targetName, parsed.timeout, absoluteTimeoutMs, traceBuilder, authState);
170
- targetLastBound = r.lastBoundVar;
171
- targetLastValue = r.lastBoundVar !== null ? vars.get(r.lastBoundVar) : r.lastValue;
172
- }
173
- catch (err) {
174
- errors.push(buildExecutionError(err, targetName));
175
- if (target.elseBlock !== undefined) {
176
- try {
177
- const r = await execOps(target.elseBlock, vars, emissions, fallbacks, ctx, targetName, parsed.timeout, absoluteTimeoutMs, traceBuilder, authState);
178
- targetLastBound = r.lastBoundVar;
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
- else if (parsed.onError !== null && ctx.fallbackSkillExecutor) {
186
- try {
187
- const fbResult = await ctx.fallbackSkillExecutor(parsed.onError, Object.fromEntries(vars));
188
- for (const em of fbResult.emissions)
189
- emissions.push(em);
190
- for (const fe of fbResult.errors)
191
- errors.push(fe);
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
- catch (fbErr) {
194
- errors.push(buildExecutionError(fbErr, parsed.onError, "skill-fallback"));
247
+ else {
248
+ break;
195
249
  }
196
- break;
197
250
  }
198
- else {
199
- break;
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
- vars.set(`${targetName}.output`, targetLastValue);
203
- if (targetLastBound !== null)
204
- lastBoundVar = targetLastBound;
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
- if (ctx.mechanical !== true && deliveryAuthorized) {
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
- rawResult = await dispatchWithTimeout(() => connector.call(toolName, dispatchArgs, ctx.agentId !== undefined ? { agentId: ctx.agentId } : undefined), timeoutMs, "$");
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
- function resolveOpTimeoutMs(perOpTimeoutSec, skillTimeoutSec, absoluteTimeoutMs, vars) {
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
- return resolveIntParam(perOpTimeoutSec, vars, "timeoutSeconds") * 1000;
1494
+ base = resolveIntParam(perOpTimeoutSec, vars, "timeoutSeconds") * 1000;
1495
+ }
1496
+ else if (skillTimeoutSec !== null) {
1497
+ base = resolveIntParam(skillTimeoutSec, vars, "# Timeout:") * 1000;
1341
1498
  }
1342
- if (skillTimeoutSec !== null) {
1343
- return resolveIntParam(skillTimeoutSec, vars, "# Timeout:") * 1000;
1499
+ else {
1500
+ base = absoluteTimeoutMs;
1344
1501
  }
1345
- return absoluteTimeoutMs;
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`-shaped
1349
- * op-error so the existing else: / # OnError: machinery catches it.
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
- * v1 caveat: timeout returns control to the executor promptly, but the
1352
- * underlying request may still complete in the background its result is
1353
- * discarded. v2 should thread AbortSignal through connector contracts so
1354
- * implementations can cancel cleanly.
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
- reject(new OpTimeoutError(timeoutMs, opKind));
1361
- }, timeoutMs);
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
- reject(new OpTimeoutError(timeoutMs, "shell"));
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) {