Skip to content

feat(cron): add bash-kind payload for zero-LLM cron execution#69455

Closed
samgibson-bot wants to merge 1 commit into
openclaw:mainfrom
samgibson-bot:feat/bash-kind-cron
Closed

feat(cron): add bash-kind payload for zero-LLM cron execution#69455
samgibson-bot wants to merge 1 commit into
openclaw:mainfrom
samgibson-bot:feat/bash-kind-cron

Conversation

@samgibson-bot

Copy link
Copy Markdown

feat(cron): add bash-kind payload for zero-LLM cron execution

Introduce a third CronPayload variant { kind: "bash", command, ... } that
runs the configured shell command directly via /bin/bash -c, captures stdout,
and routes it through the existing delivery plumbing — no agent turn spawned.

Motivation

Many cron jobs in real-world deployments are pure stdout relays: the payload
tells the isolated agent "run this bash command and print its output verbatim,"
and the agent does nothing but proxy. Every such job pays a full LLM turn per
fire (input tokens for the prompt + system context + tool definitions, output
tokens for the relayed text) to accomplish what child_process.spawn could do
for free.

On my own deployment (~20 relay-style crons firing daily to weekly), routing
these to bash-kind would eliminate ~1M LLM tokens per month with no change in
user-visible behavior. The LLM was never doing judgment work on these jobs.

Behavior

  • payload.kind: "bash" with required command: string field
  • Optional timeoutSeconds (default 300, max 3600), cwd, env, maxOutputBytes
  • Security: runs with gateway's uid/gid, no additional sandboxing (mirrors the
    posture of the existing agent bash tool — worth discussing in review)
  • Delivery: integrates with the same delivery: { mode, channel, to, accountId }
    contract as agentTurn. announce sends stdout to the channel; webhook
    POSTs it; none discards.
  • Convention: if stdout trims to exactly NO_REPLY (or empty), delivery is
    suppressed — matches the silent-on-success pattern many existing crons use
    inside their payload messages.
  • Errors (non-zero exit, timeout, spawn failure) set lastRunStatus=error so
    the existing backoff + failure-alert pipeline works unchanged.

Changes

  • src/cron/types.ts — extend CronPayload / CronPayloadPatch unions with
    CronBashPayload / CronBashPayloadPatch.
  • src/gateway/protocol/schema/cron.ts — TypeBox cronBashPayloadSchema;
    added to CronPayloadSchema + CronPayloadPatchSchema.
  • src/cron/service/jobs.tsassertSupportedJobSpec now accepts
    payload.kind=bash on isolated/current/session session targets.
  • src/cron/bash-run/run.ts (new) — runCronBashJob: spawn, capture, timeout,
    size cap, abort-signal integration.
  • src/cron/service/state.ts — add optional runBashJob dep to
    CronServiceDeps.
  • src/gateway/server-cron.ts — wire runBashJob dep: invokes
    runCronBashJob, then routes stdout through deliverOutboundPayloads
    with the same resolveDeliveryTarget + createOutboundSendDeps pattern as
    isolated-agent delivery.
  • src/cron/service/timer.tsexecuteDetachedCronJob routes kind=bash
    to the new dep; kind=agentTurn path unchanged.

What's NOT in this PR (intentionally)

  • Unit/e2e tests — would appreciate maintainer guidance on test-fixture style
    for subprocess-based cron execution before writing them.
  • CLI wiring (openclaw cron add --bash "command") — can be a follow-up PR;
    current cron add happily accepts a JSON payload with kind: "bash" via
    the schema.
  • Sandboxing beyond process isolation — open question for maintainers: should
    bash-kind crons run under a restricted user, seccomp profile, or
    container? The existing agent bash tool accepts similar trust; this PR
    adopts the same model but I'm open to tightening.
  • Model-override / thinking / toolsAllow fields — none apply to bash; schema
    reflects that via additionalProperties: false.

Compatibility

All changes are additive. payload.kind=agentTurn and =systemEvent paths
are untouched. Jobs created before this PR remain valid. New field validation
uses additionalProperties: false to reject future-incompatible shapes.

Testing done locally

  • Branch builds against the schema structure (visual inspection only; I
    haven't run full pnpm check locally since the repo needs pnpm install
    and this is a design-proposal-scale change). I'd like maintainer review on
    the overall shape before investing in test fixtures.
  • I'd appreciate a tag or comment on which test harness (server.cron.e2e.test.ts?
    normalize.test.ts?) is the right anchor for bash-kind coverage.

Alternative considered

Per-cron payload.model override to route to cheaper models (e.g. Haiku 4.5).
That's already supported by the current code (run.ts:206) and cuts cost
meaningfully (15–20x on Haiku vs Sonnet), but still pays a full agent turn
per fire. bash-kind eliminates the agent turn entirely for cases that don't
need one, and the two features compose naturally (keep agentTurn with a
cheap model for jobs that need LLM judgment; use bash-kind for everything
else).

@samgibson-bot
samgibson-bot requested a review from a team as a code owner April 20, 2026 20:41
@openclaw-barnacle openclaw-barnacle Bot added app: web-ui App: web-ui gateway Gateway runtime size: M labels Apr 20, 2026
@greptile-apps

greptile-apps Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a bash variant of CronPayload that spawns a shell command directly, bypassing the LLM agent turn for relay-style cron jobs. The types, schema, executor, and delivery wiring are all added; existing agentTurn and systemEvent paths are untouched.

Two blocking issues were found: buildPayloadFromPatch in jobs.ts has no bash case, so any cron.update call that touches a bash-kind payload will always throw a misleading agentTurn requires message error. And the runBashJob handler in server-cron.ts only performs announce delivery — webhook mode silently returns delivered: false even though onEvent does POST to the webhook, leaving lastDeliveryStatus permanently wrong for webhook-mode bash jobs.

Confidence Score: 3/5

Not safe to merge — two P1 defects (broken cron.update for bash payloads, incorrect delivery-status tracking for webhook mode) need to be fixed first.

The two P1 findings are present-tense defects on the primary changed paths: cron.update for bash-kind jobs will always throw, and webhook-mode delivery status will be persisted as not-delivered even when the webhook fires successfully. Both are regressions introduced by this PR visible to any user who creates a bash-kind cron job.

src/cron/service/jobs.ts (mergeCronPayload/buildPayloadFromPatch) and src/gateway/server-cron.ts (runBashJob webhook path) require fixes before merge.

Comments Outside Diff (1)

  1. src/cron/service/jobs.ts, line 740-763 (link)

    P1 buildPayloadFromPatch has no bash case — patch always throws

    mergeCronPayload falls through to buildPayloadFromPatch whenever existing.kind !== "agentTurn" (line 706–708), which includes every bash-kind job. Inside buildPayloadFromPatch, only "systemEvent" is handled; everything else is treated as "agentTurn" and immediately throws 'cron.update payload.kind="agentTurn" requires message'. This means any cron.update call that touches the payload of a bash job — including a same-kind update to change command — will always error with a misleading message.

    A minimal fix is to add a bash branch in both functions:

    // in buildPayloadFromPatch:
    if (patch.kind === "bash") {
      if (typeof patch.command !== "string" || patch.command.length === 0) {
        throw new Error('cron.update payload.kind="bash" requires command');
      }
      return { kind: "bash", command: patch.command, ...rest };
    }
    

    And in mergeCronPayload, add a bash-aware merge before the agentTurn guard (similar to how systemEvent is merged in place instead of rebuilt).

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/cron/service/jobs.ts
    Line: 740-763
    
    Comment:
    **`buildPayloadFromPatch` has no `bash` case — patch always throws**
    
    `mergeCronPayload` falls through to `buildPayloadFromPatch` whenever `existing.kind !== "agentTurn"` (line 706–708), which includes every bash-kind job. Inside `buildPayloadFromPatch`, only `"systemEvent"` is handled; everything else is treated as `"agentTurn"` and immediately throws `'cron.update payload.kind="agentTurn" requires message'`. This means any `cron.update` call that touches the payload of a bash job — including a same-kind update to change `command` — will always error with a misleading message.
    
    A minimal fix is to add a bash branch in both functions:
    
    ```
    // in buildPayloadFromPatch:
    if (patch.kind === "bash") {
      if (typeof patch.command !== "string" || patch.command.length === 0) {
        throw new Error('cron.update payload.kind="bash" requires command');
      }
      return { kind: "bash", command: patch.command, ...rest };
    }
    ```
    
    And in `mergeCronPayload`, add a bash-aware merge before the agentTurn guard (similar to how `systemEvent` is merged in place instead of rebuilt).
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/cron/service/jobs.ts
Line: 740-763

Comment:
**`buildPayloadFromPatch` has no `bash` case — patch always throws**

`mergeCronPayload` falls through to `buildPayloadFromPatch` whenever `existing.kind !== "agentTurn"` (line 706–708), which includes every bash-kind job. Inside `buildPayloadFromPatch`, only `"systemEvent"` is handled; everything else is treated as `"agentTurn"` and immediately throws `'cron.update payload.kind="agentTurn" requires message'`. This means any `cron.update` call that touches the payload of a bash job — including a same-kind update to change `command` — will always error with a misleading message.

A minimal fix is to add a bash branch in both functions:

```
// in buildPayloadFromPatch:
if (patch.kind === "bash") {
  if (typeof patch.command !== "string" || patch.command.length === 0) {
    throw new Error('cron.update payload.kind="bash" requires command');
  }
  return { kind: "bash", command: patch.command, ...rest };
}
```

And in `mergeCronPayload`, add a bash-aware merge before the agentTurn guard (similar to how `systemEvent` is merged in place instead of rebuilt).

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/server-cron.ts
Line: 351-383

Comment:
**Webhook mode silently returns `delivered: false` for bash-kind**

The `runBashJob` handler only checks `mode === "announce"` (line 363). When `delivery.mode === "webhook"`, the block is skipped entirely and the function returns `{ ...result, delivered: false, deliveryAttempted: false }`. The `onEvent` handler does fire a webhook via `postCronWebhook` (using `evt.summary`), but because `delivered` is already set to `false` here, `job.state.lastDeliveryStatus` will persist as `"not-delivered"` even when the webhook POST actually succeeded. This corrupts the delivery-status tracking that failure alerting and run-log reporting depend on.

The fix is to also handle `mode === "webhook"` inside `runBashJob` — or, if the intent is to rely on `onEvent` for webhooks, set `deliveryAttempted: true` and `delivered: true` when `mode === "webhook"` so the stored state reflects what actually happens.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/server-cron.ts
Line: 354-362

Comment:
**`agentId` is `undefined` when resolving bash delivery target**

`resolveDeliveryTarget` is called with `undefined` as the second argument (the `agentId` position). The analogous `runIsolatedAgentJob` path calls `resolveCronAgent(job.agentId)` first and passes the resolved id. If delivery-target resolution performs any agent-scoped lookup (e.g., for per-agent channel plugins), it will silently fall back to the default agent, which may differ from `job.agentId`. Consider mirroring the `runIsolatedAgentJob` pattern:

```suggestion
      const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
      const target = await resolveDeliveryTarget(
        runtimeConfig,
        agentId,
        {
          channel: job.delivery?.channel,
          to: job.delivery?.to,
          accountId: job.delivery?.accountId,
        },
      );
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/cron/bash-run/run.ts
Line: 81-88

Comment:
**Abort path sends SIGTERM but no SIGKILL escalation**

The timeout path (lines 63–78) sends SIGTERM followed by a 5-second deferred SIGKILL. The `onAbort` handler only sends SIGTERM and has no escalation. A process that catches and ignores SIGTERM will run indefinitely when aborted, potentially leaking the subprocess. Consider mirroring the timeout escalation pattern in `onAbort`:

```suggestion
    const onAbort = () => {
      timedOut = true;
      try {
        child.kill("SIGTERM");
        setTimeout(() => {
          try { child.kill("SIGKILL"); } catch { /* already gone */ }
        }, 5000).unref();
      } catch {
        /* ignore */
      }
    };
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(cron): add bash-kind payload for ze..." | Re-trigger Greptile

Comment on lines +351 to +383
// Otherwise, hand stdout to the same delivery plumbing used by agentTurn.
if (result.status === "ok" && result.outputText) {
try {
const target = await resolveDeliveryTarget(
params.cfg,
undefined,
{
channel: job.delivery?.channel,
to: job.delivery?.to,
accountId: job.delivery?.accountId,
},
);
if (target.ok && job.delivery && job.delivery.mode === "announce") {
await deliverOutboundPayloads({
cfg: params.cfg,
channel: target.channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [{ text: result.outputText }],
deps: createOutboundSendDeps(params.deps),
});
return { ...result, delivered: true, deliveryAttempted: true };
}
} catch (err) {
return {
...result,
status: "error",
error: `bash delivery failed: ${String(err)}`,
};
}
}
return { ...result, delivered: false, deliveryAttempted: false };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Webhook mode silently returns delivered: false for bash-kind

The runBashJob handler only checks mode === "announce" (line 363). When delivery.mode === "webhook", the block is skipped entirely and the function returns { ...result, delivered: false, deliveryAttempted: false }. The onEvent handler does fire a webhook via postCronWebhook (using evt.summary), but because delivered is already set to false here, job.state.lastDeliveryStatus will persist as "not-delivered" even when the webhook POST actually succeeded. This corrupts the delivery-status tracking that failure alerting and run-log reporting depend on.

The fix is to also handle mode === "webhook" inside runBashJob — or, if the intent is to rely on onEvent for webhooks, set deliveryAttempted: true and delivered: true when mode === "webhook" so the stored state reflects what actually happens.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-cron.ts
Line: 351-383

Comment:
**Webhook mode silently returns `delivered: false` for bash-kind**

The `runBashJob` handler only checks `mode === "announce"` (line 363). When `delivery.mode === "webhook"`, the block is skipped entirely and the function returns `{ ...result, delivered: false, deliveryAttempted: false }`. The `onEvent` handler does fire a webhook via `postCronWebhook` (using `evt.summary`), but because `delivered` is already set to `false` here, `job.state.lastDeliveryStatus` will persist as `"not-delivered"` even when the webhook POST actually succeeded. This corrupts the delivery-status tracking that failure alerting and run-log reporting depend on.

The fix is to also handle `mode === "webhook"` inside `runBashJob` — or, if the intent is to rely on `onEvent` for webhooks, set `deliveryAttempted: true` and `delivered: true` when `mode === "webhook"` so the stored state reflects what actually happens.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +354 to +362
const target = await resolveDeliveryTarget(
params.cfg,
undefined,
{
channel: job.delivery?.channel,
to: job.delivery?.to,
accountId: job.delivery?.accountId,
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 agentId is undefined when resolving bash delivery target

resolveDeliveryTarget is called with undefined as the second argument (the agentId position). The analogous runIsolatedAgentJob path calls resolveCronAgent(job.agentId) first and passes the resolved id. If delivery-target resolution performs any agent-scoped lookup (e.g., for per-agent channel plugins), it will silently fall back to the default agent, which may differ from job.agentId. Consider mirroring the runIsolatedAgentJob pattern:

Suggested change
const target = await resolveDeliveryTarget(
params.cfg,
undefined,
{
channel: job.delivery?.channel,
to: job.delivery?.to,
accountId: job.delivery?.accountId,
},
);
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
const target = await resolveDeliveryTarget(
runtimeConfig,
agentId,
{
channel: job.delivery?.channel,
to: job.delivery?.to,
accountId: job.delivery?.accountId,
},
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-cron.ts
Line: 354-362

Comment:
**`agentId` is `undefined` when resolving bash delivery target**

`resolveDeliveryTarget` is called with `undefined` as the second argument (the `agentId` position). The analogous `runIsolatedAgentJob` path calls `resolveCronAgent(job.agentId)` first and passes the resolved id. If delivery-target resolution performs any agent-scoped lookup (e.g., for per-agent channel plugins), it will silently fall back to the default agent, which may differ from `job.agentId`. Consider mirroring the `runIsolatedAgentJob` pattern:

```suggestion
      const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
      const target = await resolveDeliveryTarget(
        runtimeConfig,
        agentId,
        {
          channel: job.delivery?.channel,
          to: job.delivery?.to,
          accountId: job.delivery?.accountId,
        },
      );
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/cron/bash-run/run.ts
Comment on lines +81 to +88
const onAbort = () => {
timedOut = true;
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Abort path sends SIGTERM but no SIGKILL escalation

The timeout path (lines 63–78) sends SIGTERM followed by a 5-second deferred SIGKILL. The onAbort handler only sends SIGTERM and has no escalation. A process that catches and ignores SIGTERM will run indefinitely when aborted, potentially leaking the subprocess. Consider mirroring the timeout escalation pattern in onAbort:

Suggested change
const onAbort = () => {
timedOut = true;
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
};
const onAbort = () => {
timedOut = true;
try {
child.kill("SIGTERM");
setTimeout(() => {
try { child.kill("SIGKILL"); } catch { /* already gone */ }
}, 5000).unref();
} catch {
/* ignore */
}
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/bash-run/run.ts
Line: 81-88

Comment:
**Abort path sends SIGTERM but no SIGKILL escalation**

The timeout path (lines 63–78) sends SIGTERM followed by a 5-second deferred SIGKILL. The `onAbort` handler only sends SIGTERM and has no escalation. A process that catches and ignores SIGTERM will run indefinitely when aborted, potentially leaking the subprocess. Consider mirroring the timeout escalation pattern in `onAbort`:

```suggestion
    const onAbort = () => {
      timedOut = true;
      try {
        child.kill("SIGTERM");
        setTimeout(() => {
          try { child.kill("SIGKILL"); } catch { /* already gone */ }
        }, 5000).unref();
      } catch {
        /* ignore */
      }
    };
```

How can I resolve this? If you propose a fix, please make it concise.

Introduce a third CronPayload variant `{ kind: "bash", command, ... }` that
runs the configured shell command directly via /bin/bash -c, captures stdout,
and routes it through the existing delivery plumbing — no agent turn spawned.

## Motivation

Many cron jobs in real-world deployments are pure stdout relays: the payload
tells the isolated agent "run this bash command and print its output verbatim,"
and the agent does nothing but proxy. Every such job pays a full LLM turn per
fire (input tokens for the prompt + system context + tool definitions, output
tokens for the relayed text) to accomplish what `child_process.spawn` could do
for free.

On my own deployment (~20 relay-style crons firing daily to weekly), routing
these to bash-kind would eliminate ~1M LLM tokens per month with no change in
user-visible behavior. The LLM was never doing judgment work on these jobs.

## Behavior

- `payload.kind: "bash"` with required `command: string` field
- Optional `timeoutSeconds` (default 300, max 3600), `cwd`, `env`, `maxOutputBytes`
- Security: runs with gateway's uid/gid, no additional sandboxing (mirrors the
  posture of the existing agent `bash` tool — worth discussing in review)
- Delivery: integrates with the same `delivery: { mode, channel, to, accountId }`
  contract as `agentTurn`. `announce` sends stdout to the channel; `webhook`
  POSTs it; `none` discards.
- Convention: if stdout trims to exactly `NO_REPLY` (or empty), delivery is
  suppressed — matches the silent-on-success pattern many existing crons use
  inside their payload messages.
- Errors (non-zero exit, timeout, spawn failure) set `lastRunStatus=error` so
  the existing backoff + failure-alert pipeline works unchanged.

## Changes

- `src/cron/types.ts` — extend `CronPayload` / `CronPayloadPatch` unions with
  `CronBashPayload` / `CronBashPayloadPatch`.
- `src/gateway/protocol/schema/cron.ts` — TypeBox `cronBashPayloadSchema`;
  added to `CronPayloadSchema` + `CronPayloadPatchSchema`.
- `src/cron/service/jobs.ts` — `assertSupportedJobSpec` now accepts
  `payload.kind=bash` on isolated/current/session session targets.
- `src/cron/bash-run/run.ts` (new) — `runCronBashJob`: spawn, capture, timeout,
  size cap, abort-signal integration.
- `src/cron/service/state.ts` — add optional `runBashJob` dep to
  `CronServiceDeps`.
- `src/gateway/server-cron.ts` — wire `runBashJob` dep: invokes
  `runCronBashJob`, then routes stdout through `deliverOutboundPayloads`
  with the same `resolveDeliveryTarget` + `createOutboundSendDeps` pattern as
  isolated-agent delivery.
- `src/cron/service/timer.ts` — `executeDetachedCronJob` routes `kind=bash`
  to the new dep; `kind=agentTurn` path unchanged.

## What's NOT in this PR (intentionally)

- Unit/e2e tests — would appreciate maintainer guidance on test-fixture style
  for subprocess-based cron execution before writing them.
- CLI wiring (`openclaw cron add --bash "command"`) — can be a follow-up PR;
  current `cron add` happily accepts a JSON payload with `kind: "bash"` via
  the schema.
- Sandboxing beyond process isolation — open question for maintainers: should
  bash-kind crons run under a restricted user, seccomp profile, or
  container? The existing agent `bash` tool accepts similar trust; this PR
  adopts the same model but I'm open to tightening.
- Model-override / thinking / toolsAllow fields — none apply to bash; schema
  reflects that via `additionalProperties: false`.

## Compatibility

All changes are additive. `payload.kind=agentTurn` and `=systemEvent` paths
are untouched. Jobs created before this PR remain valid. New field validation
uses `additionalProperties: false` to reject future-incompatible shapes.

## Testing done locally

- Branch builds against the schema structure (visual inspection only; I
  haven't run full `pnpm check` locally since the repo needs `pnpm install`
  and this is a design-proposal-scale change). I'd like maintainer review on
  the overall shape before investing in test fixtures.
- I'd appreciate a tag or comment on which test harness (`server.cron.e2e.test.ts`?
  `normalize.test.ts`?) is the right anchor for bash-kind coverage.

## Alternative considered

Per-cron `payload.model` override to route to cheaper models (e.g. Haiku 4.5).
That's already supported by the current code (`run.ts:206`) and cuts cost
meaningfully (15–20x on Haiku vs Sonnet), but still pays a full agent turn
per fire. bash-kind eliminates the agent turn entirely for cases that don't
need one, and the two features compose naturally (keep agentTurn with a
cheap model for jobs that need LLM judgment; use bash-kind for everything
else).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff61a11806

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/gateway/protocol/schema/cron.ts Outdated
@@ -153,6 +168,7 @@ export const CronPayloadPatchSchema = Type.Union([
message: Type.Optional(NonEmptyString),
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
}),
cronBashPayloadSchema({ command: Type.Optional(NonEmptyString) }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle bash payload patches in cron.update

CronPayloadPatchSchema now accepts kind: "bash", but the service merge path still only has systemEvent/agentTurn branches (mergeCronPayload/buildPayloadFromPatch in src/cron/service/jobs.ts). As a result, updating an existing job to bash (or patching an existing bash payload) falls into the agentTurn validation path and throws cron.update payload.kind="agentTurn" requires message, making the new patch shape unusable.

Useful? React with 👍 / 👎.

Comment thread src/gateway/server-cron.ts Outdated
Comment on lines +363 to +364
if (target.ok && job.delivery && job.delivery.mode === "announce") {
await deliverOutboundPayloads({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail bash runs when announce target resolution fails

In the bash execution path, delivery only happens inside if (target.ok && ... mode === "announce"); when target resolution fails, the function falls through and returns an ok result with delivered: false. This silently drops output and bypasses error/backoff/failure-alert handling for common cases like delivery.channel="last" without a resolvable target, unlike the existing agentTurn delivery path which reports delivery-target errors.

Useful? React with 👍 / 👎.

Comment thread src/gateway/protocol/schema/cron.ts Outdated
{
kind: Type.Literal("bash"),
command: params.command,
timeoutSeconds: Type.Optional(Type.Number({ minimum: 1, maximum: 3600 })),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align outer cron timeout with bash timeoutSeconds

The schema advertises bash timeoutSeconds up to 3600 seconds, but the scheduler safety timeout is still computed as 10 minutes for all non-agentTurn payloads (resolveCronJobTimeoutMs in src/cron/service/timeout-policy.ts). That means any bash job configured above 600 seconds is aborted early by the outer timeout, so accepted configuration values do not actually take effect.

Useful? React with 👍 / 👎.

@samgibson-bot samgibson-bot closed this by deleting the head repository Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui gateway Gateway runtime size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant