Skip to content

Commit 376a024

Browse files
committed
fix: stabilize sessions-yield cron lifecycle
Preserve yielded parent runs until subagents settle, tighten cron session lifecycle handling, and keep approval/i18n review fixes compatible with the pinned upstream base.
1 parent b573a3b commit 376a024

84 files changed

Lines changed: 2758 additions & 662 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/automation/cron-jobs.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ For template files, keep the language instruction in the rendered prompt and ver
326326
</Tab>
327327
</Tabs>
328328

329-
## Managing jobs
329+
## Managing cron jobs from the CLI
330330

331331
```bash
332332
# List all jobs
@@ -534,6 +534,63 @@ When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `
534534

535535
Use the latest-generation, best-tier model available from your provider for untrusted inboxes. The value above is an example; the model must exist in your configured catalog and allowlist.
536536

537+
## Managing jobs
538+
539+
```bash
540+
# List all jobs
541+
openclaw cron list
542+
543+
# Get one stored job as JSON
544+
openclaw cron get <jobId>
545+
546+
# Show one job, including resolved delivery route
547+
openclaw cron show <jobId>
548+
549+
# Edit a job
550+
openclaw cron edit <jobId> --message "Updated prompt" --model "opus"
551+
552+
# Force run a job now
553+
openclaw cron run <jobId>
554+
555+
# Force run a job now and wait for its terminal status
556+
openclaw cron run <jobId> --wait --wait-timeout 10m --poll-interval 2s
557+
558+
# Run only if due
559+
openclaw cron run <jobId> --due
560+
561+
# View run history
562+
openclaw cron runs --id <jobId> --limit 50
563+
564+
# View one exact run
565+
openclaw cron runs --id <jobId> --run-id <runId>
566+
567+
# Delete a job
568+
openclaw cron remove <jobId>
569+
570+
# Agent selection (multi-agent setups)
571+
openclaw cron create "0 6 * * *" "Check ops queue" --name "Ops sweep" --session isolated --agent ops
572+
openclaw cron edit <jobId> --clear-agent
573+
```
574+
575+
`openclaw cron run <jobId>` returns after enqueueing the manual run. Use `--wait` for shutdown hooks, maintenance scripts, or other automation that must block until the queued run finishes or is handed off. Wait mode polls the exact returned `runId`; it exits `0` for status `ok` and non-zero for `error`, `skipped`, `deferred`, or a wait timeout.
576+
577+
The agent `cron` tool returns compact job summaries (`id`, `name`, `enabled`, `nextRunAtMs`, `scheduleKind`, `lastRunStatus`) from `cron(action: "list")`; use `cron(action: "get", jobId: "...")` for one full job definition. Direct Gateway callers can pass `compact: true` to `cron.list`; omitting it preserves the existing full response with delivery previews.
578+
579+
`openclaw cron create` is an alias for `openclaw cron add`, and new jobs can use a positional schedule (`"0 9 * * 1"`, `"every 1h"`, `"20m"`, or an ISO timestamp) followed by a positional agent prompt. Use `--webhook <url>` on `cron add|create` or `cron edit` to POST the finished run payload to an HTTP endpoint. Webhook delivery cannot be combined with chat delivery flags such as `--announce`, `--channel`, `--to`, `--thread-id`, or `--account`. On `cron edit`, `--clear-channel`, `--clear-to`, `--clear-thread-id`, and `--clear-account` unset those routing fields individually (each rejected alongside its matching set flag), which is distinct from `--no-deliver` disabling runner fallback delivery.
580+
581+
<Note>
582+
Model override note:
583+
584+
- `openclaw cron add|edit --model ...` changes the job's selected model.
585+
- If the model is allowed, that exact provider/model reaches the isolated agent run.
586+
- If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error.
587+
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
588+
- `openclaw cron edit <job-id> --clear-model` clears that override from the CLI (same effect as the `model: null` patch) and cannot be combined with `--model`.
589+
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
590+
- `openclaw cron add|edit --fallbacks ...` sets payload `fallbacks`, replacing configured fallbacks for that job; `--fallbacks ""` disables fallback and makes the run strict. `openclaw cron edit <job-id> --clear-fallbacks` clears the per-job override.
591+
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.
592+
593+
</Note>
537594
## Configuration
538595

539596
```json5

docs/cli/cron.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Add `--wait` when a script should block until that exact queued run records a te
149149
openclaw cron run <job-id> --wait --wait-timeout 10m --poll-interval 2s
150150
```
151151

152-
With `--wait`, the CLI still calls `cron.run` first, then polls `cron.runs` for the returned `runId`. The command exits `0` only when the run finishes with status `ok`. It exits non-zero when the run finishes with `error` or `skipped`, when the Gateway response does not include a `runId`, or when `--wait-timeout` expires (default `10m`, polled every `2s` by default). `--poll-interval` must be greater than zero.
152+
With `--wait`, the CLI still calls `cron.run` first, then polls `cron.runs` for the returned `runId`. The command exits `0` only when the run finishes with status `ok`. It exits non-zero when the run finishes with `error`, `skipped`, or `deferred`, when the Gateway response does not include a `runId`, or when `--wait-timeout` expires. `--poll-interval` must be greater than zero.
153153

154154
<Note>
155155
Use `--due` when you want the manual command to run only if the job is currently due. If `--due --wait` does not enqueue a run, the command returns the normal non-run response instead of polling.
@@ -302,7 +302,7 @@ openclaw cron runs --id <job-id> --run-id <run-id>
302302

303303
`openclaw cron get <job-id>` returns the stored job JSON directly. Use `cron show <job-id>` when you want the human-readable view with delivery-route preview.
304304

305-
`cron list --json` and `cron show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. JSON status stays canonical and undecorated so external tooling can read job state without re-deriving it; human output may decorate repeated `error` statuses with a failure count.
305+
`cron list --json` and `cron show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, `deferred`, or `idle`. This mirrors the human-readable status column so external tooling can read job state without re-deriving it.
306306

307307
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
308308

docs/docs_map.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
7575
- H3: Failure notifications
7676
- H3: Output language
7777
- H2: CLI examples
78-
- H2: Managing jobs
78+
- H2: Managing cron jobs from the CLI
7979
- H2: Webhooks
8080
- H3: Authentication
8181
- H2: Gmail PubSub integration
8282
- H3: Wizard setup (recommended)
8383
- H3: Gateway auto-start
8484
- H3: Manual one-time setup
8585
- H3: Gmail model override
86+
- H2: Managing jobs
8687
- H2: Configuration
8788
- H2: Troubleshooting
8889
- H3: Command ladder

docs/tools/subagents.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ the current child sessions, run ids, statuses, labels, tasks, and
273273
block are quoted as data, not instructions, because they can originate
274274
from user/model-provided spawn arguments.
275275

276+
If a human sends another message while a requester is yielded, that message
277+
starts a normal turn for the same requester session. OpenClaw keeps the yielded
278+
run visible as waiting on descendant completion rather than converting the
279+
yielded turn into a stale final reply. When the descendant completion arrives,
280+
the completion event is handed back to the requester for synthesis.
281+
276282
## Tool: `subagents`
277283

278284
Lists spawned sub-agent runs owned by the requester session. It is scoped
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const deleteMessageDiscord = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
5+
6+
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
7+
resolveAckReaction: vi.fn(() => null),
8+
}));
9+
10+
vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
11+
createStatusReactionController: vi.fn(() => ({
12+
setDone: vi.fn(async () => {}),
13+
setError: vi.fn(async () => {}),
14+
})),
15+
}));
16+
17+
vi.mock("../send.messages.js", () => ({
18+
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
19+
}));
20+
21+
describe("discord progress settlement", () => {
22+
beforeEach(async () => {
23+
deleteMessageDiscord.mockReset();
24+
deleteMessageDiscord.mockResolvedValue(undefined);
25+
});
26+
27+
it("settles the yielded source message without consuming another turn in the same session", async () => {
28+
const { registerDiscordProgressSettlement, settleDiscordProgressForSession } =
29+
await import("./discord-progress-settlement.js");
30+
const cfg = {} as OpenClawConfig;
31+
const rest = {} as never;
32+
33+
await registerDiscordProgressSettlement({
34+
sessionKey: "agent:main:discord:guild:g1",
35+
settlementKey: "source-message-a",
36+
accountId: "a1",
37+
channelId: "c1",
38+
statusMessageId: "status-a",
39+
progressMessageId: "preview-a",
40+
});
41+
await registerDiscordProgressSettlement({
42+
sessionKey: "agent:main:discord:guild:g1",
43+
settlementKey: "source-message-b",
44+
accountId: "a1",
45+
channelId: "c1",
46+
statusMessageId: "status-b",
47+
progressMessageId: "preview-b",
48+
});
49+
50+
await expect(
51+
settleDiscordProgressForSession({
52+
cfg,
53+
discordConfig: undefined,
54+
token: "token",
55+
rest,
56+
sessionKey: "agent:main:discord:guild:g1",
57+
settlementKey: "source-message-b",
58+
status: "ok",
59+
}),
60+
).resolves.toBe(true);
61+
62+
expect(deleteMessageDiscord).toHaveBeenCalledTimes(1);
63+
expect(deleteMessageDiscord).toHaveBeenCalledWith("c1", "preview-b", {
64+
cfg,
65+
token: "token",
66+
rest,
67+
});
68+
69+
deleteMessageDiscord.mockClear();
70+
await expect(
71+
settleDiscordProgressForSession({
72+
cfg,
73+
discordConfig: undefined,
74+
token: "token",
75+
rest,
76+
sessionKey: "agent:main:discord:guild:g1",
77+
settlementKey: "source-message-a",
78+
status: "ok",
79+
}),
80+
).resolves.toBe(true);
81+
82+
expect(deleteMessageDiscord).toHaveBeenCalledWith("c1", "preview-a", {
83+
cfg,
84+
token: "token",
85+
rest,
86+
});
87+
});
88+
89+
it("retains settlement ownership when Discord finalization fails so hooks can retry", async () => {
90+
const { registerDiscordProgressSettlement, settleDiscordProgressForSession } =
91+
await import("./discord-progress-settlement.js");
92+
const cfg = {} as OpenClawConfig;
93+
const rest = {} as never;
94+
95+
await registerDiscordProgressSettlement({
96+
sessionKey: "agent:main:discord:guild:g1",
97+
settlementKey: "source-message-1",
98+
accountId: "a1",
99+
channelId: "c1",
100+
statusMessageId: "status-1",
101+
progressMessageId: "preview-1",
102+
});
103+
104+
deleteMessageDiscord.mockRejectedValueOnce(new Error("discord temporarily unavailable"));
105+
await expect(
106+
settleDiscordProgressForSession({
107+
cfg,
108+
discordConfig: undefined,
109+
token: "token",
110+
rest,
111+
sessionKey: "agent:main:discord:guild:g1",
112+
settlementKey: "source-message-1",
113+
status: "ok",
114+
}),
115+
).resolves.toBe(false);
116+
117+
deleteMessageDiscord.mockClear();
118+
await expect(
119+
settleDiscordProgressForSession({
120+
cfg,
121+
discordConfig: undefined,
122+
token: "token",
123+
rest,
124+
sessionKey: "agent:main:discord:guild:g1",
125+
settlementKey: "source-message-1",
126+
status: "ok",
127+
}),
128+
).resolves.toBe(true);
129+
130+
expect(deleteMessageDiscord).toHaveBeenCalledWith("c1", "preview-1", {
131+
cfg,
132+
token: "token",
133+
rest,
134+
});
135+
});
136+
});

0 commit comments

Comments
 (0)