feat: add announce delivery mode for automation runs
This commit is contained in:
@@ -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}}`) |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user