feat: add announce delivery mode for automation runs

This commit is contained in:
William Valentin
2026-02-18 10:14:45 -08:00
parent f38fc063d2
commit 865068b71c
9 changed files with 85 additions and 9 deletions
+3 -2
View File
@@ -644,6 +644,7 @@ Schedule automated messages on cron schedules. Each job fires an inbound message
Set `automation.delivery_mode` to control automation session behavior:
- `shared_session` (default): reuse one session per cron job/webhook name.
- `isolated_job`: create a fresh session per cron trigger/webhook request.
- `announce`: create a fresh ephemeral announce-style run per trigger (no shared automation history).
```yaml
automation:
@@ -704,7 +705,7 @@ automation:
| Field | Required | Description |
|-------|----------|-------------|
| `automation.delivery_mode` | no | Automation session strategy: `shared_session` or `isolated_job` (default: `shared_session`) |
| `automation.delivery_mode` | no | Automation session strategy: `shared_session`, `isolated_job`, or `announce` (default: `shared_session`) |
| `name` | yes | Unique job identifier |
| `schedule` | yes | Cron expression (standard 5-field) |
| `message` | yes | Text sent to the agent when the job fires |
@@ -803,7 +804,7 @@ Webhooks are available at `POST /webhooks/:name` on the gateway HTTP server. The
| Field | Required | Description |
|-------|----------|-------------|
| `automation.delivery_mode` | no | Automation session strategy: `shared_session` or `isolated_job` (default: `shared_session`) |
| `automation.delivery_mode` | no | Automation session strategy: `shared_session`, `isolated_job`, or `announce` (default: `shared_session`) |
| `name` | yes | Unique webhook identifier (used in URL path) |
| `secret` | no | HMAC secret for `X-Webhook-Signature` header verification (SHA-256) |
| `message` | no | Template for the message sent to the agent (default: `{{body}}`) |
+1
View File
@@ -324,6 +324,7 @@ hooks:
# automation:
# # shared_session: keep one session per cron job/webhook name.
# # isolated_job: create a fresh session per cron trigger/webhook request.
# # announce: create a fresh announce-style run per trigger (no shared automation history).
# delivery_mode: shared_session
# cron:
# - name: daily-summary
+20 -2
View File
@@ -5196,10 +5196,28 @@
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/config/schema.test.ts src/gateway/session-bridge.test.ts src/gateway/handlers/agent.test.ts + pnpm typecheck passing"
},
"automation-announce-delivery-mode": {
"status": "completed",
"date": "2026-02-18",
"updated": "2026-02-18",
"summary": "Implemented Tier A3 proactive announce delivery mode as a first-class automation option (`automation.delivery_mode: announce`) for cron/webhooks. Added explicit announce-mode sender/metadata wiring, schema/docs/default-config updates, and regression tests.",
"files_modified": [
"src/config/schema.ts",
"src/config/schema.test.ts",
"src/automation/cron.ts",
"src/automation/cron.test.ts",
"src/automation/webhooks.ts",
"src/automation/webhooks.test.ts",
"README.md",
"config/default.yaml",
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/automation/cron.test.ts src/automation/webhooks.test.ts src/config/schema.test.ts + pnpm typecheck passing"
}
},
"overall_progress": {
"total_test_count": 1908,
"total_test_count": 1911,
"all_tests_passing": true,
"p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)",
@@ -5219,7 +5237,7 @@
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
"next_up": "Implement Tier A3 from the OpenClaw roadmap: proactive announce delivery mode for automation jobs"
"next_up": "Implement Tier A4 from the OpenClaw roadmap: TTS voice output with channel-aware audio responses"
},
"soul_md_and_cron_create": {
"date": "2026-02-11",
+16
View File
@@ -98,6 +98,22 @@ describe('CronScheduler', () => {
expect(messages[0].metadata?.deliveryMode).toBe('isolated_job');
});
it('uses announce sender IDs and metadata when delivery mode is announce', async () => {
const jobs = [makeCronJob()];
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry), 'announce');
const messages: InboundMessage[] = [];
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
await scheduler.connect();
scheduler.triggerJob('test-job');
expect(messages).toHaveLength(1);
expect(messages[0].senderId).toMatch(/^test-job:announce:run-/);
expect(messages[0].metadata?.replyPeerId).toBe('test-job');
expect(messages[0].metadata?.deliveryMode).toBe('announce');
expect(messages[0].metadata?.announce).toBe(true);
});
it('forwards response to output channel on send()', async () => {
const mockOutputAdapter = {
send: vi.fn().mockResolvedValue(undefined),
+7 -2
View File
@@ -9,7 +9,7 @@ interface ChannelLookup {
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
}
type DeliveryMode = 'shared_session' | 'isolated_job';
type DeliveryMode = 'shared_session' | 'isolated_job' | 'announce';
export class CronScheduler implements ChannelAdapter {
readonly name = 'cron';
@@ -99,7 +99,11 @@ export class CronScheduler implements ChannelAdapter {
this.lastTriggeredLocalDateByJob.set(jobName, dayKey);
}
const runId = `run-${randomUUID()}`;
const senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName;
const senderId = this.deliveryMode === 'shared_session'
? jobName
: this.deliveryMode === 'announce'
? `${jobName}:announce:${runId}`
: `${jobName}:${runId}`;
const msg: InboundMessage = {
id: `cron-${jobName}-${Date.now()}`,
@@ -113,6 +117,7 @@ export class CronScheduler implements ChannelAdapter {
scheduled: true,
modelTier: job.model_tier,
deliveryMode: this.deliveryMode,
announce: this.deliveryMode === 'announce',
runId,
replyPeerId: jobName,
},
+20
View File
@@ -137,6 +137,26 @@ describe('WebhookHandler', () => {
expect(messages[0].metadata?.deliveryMode).toBe('isolated_job');
});
it('handleRequest uses announce sender IDs and metadata when delivery mode is announce', async () => {
const webhooks = [makeWebhook()];
handler = new WebhookHandler(webhooks, asWebhookChannelLookup(mockChannelRegistry), 'announce');
const messages: InboundMessage[] = [];
handler.onMessage((msg: InboundMessage) => messages.push(msg));
await handler.connect();
const req = mockRequest('hello world');
const res = mockResponse();
const result = await handler.handleRequest('test-hook', req, res);
expect(result).toBe(true);
expect(messages).toHaveLength(1);
expect(messages[0].senderId).toMatch(/^test-hook:announce:run-/);
expect(messages[0].metadata?.replyPeerId).toBe('test-hook');
expect(messages[0].metadata?.deliveryMode).toBe('announce');
expect(messages[0].metadata?.announce).toBe(true);
});
it('returns false for unknown webhook', async () => {
handler = new WebhookHandler([], asWebhookChannelLookup(mockChannelRegistry));
await handler.connect();
+7 -2
View File
@@ -10,7 +10,7 @@ interface ChannelLookup {
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
}
type DeliveryMode = 'shared_session' | 'isolated_job';
type DeliveryMode = 'shared_session' | 'isolated_job' | 'announce';
/** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */
function verifyHmac(body: string, secret: string, signature: string): boolean {
@@ -162,7 +162,11 @@ export class WebhookHandler implements ChannelAdapter {
// Render message template
const text = renderTemplate(webhook.message, body);
const runId = `run-${randomUUID()}`;
const senderId = this.deliveryMode === 'isolated_job' ? `${webhookName}:${runId}` : webhookName;
const senderId = this.deliveryMode === 'shared_session'
? webhookName
: this.deliveryMode === 'announce'
? `${webhookName}:announce:${runId}`
: `${webhookName}:${runId}`;
const msg: InboundMessage = {
id: `webhook-${webhookName}-${Date.now()}`,
@@ -175,6 +179,7 @@ export class WebhookHandler implements ChannelAdapter {
webhookName,
body,
deliveryMode: this.deliveryMode,
announce: this.deliveryMode === 'announce',
runId,
replyPeerId: webhookName,
},
+10
View File
@@ -1059,6 +1059,16 @@ describe('configSchema automation', () => {
expect(result.automation.delivery_mode).toBe('isolated_job');
});
it('accepts announce automation delivery mode', () => {
const result = configSchema.parse({
...baseConfig,
automation: {
delivery_mode: 'announce',
},
});
expect(result.automation.delivery_mode).toBe('announce');
});
it('accepts config with cron jobs', () => {
const result = configSchema.parse({
...baseConfig,
+1 -1
View File
@@ -417,7 +417,7 @@ const minioSyncAutomationSchema = z.object({
notify_on_success: z.boolean().default(false),
}).default({});
const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job']);
const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job', 'announce']);
const automationSchema = z.object({
/** Session strategy for automation-triggered runs (cron/webhooks/gmail). */