mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 08:02:11 +00:00
- Three event types: immediate, one-shot, periodic (cron) - Events are JSON files in workspace/events/ - EventsWatcher with fs.watch, 100ms debounce - Queue integration via SlackBot.enqueueEvent() (max 5) - Fix setTyping race condition causing duplicate messages - System prompt documents events for mom - Design doc in docs/events.md - Add croner dependency for cron scheduling
300 lines
9.3 KiB
Markdown
300 lines
9.3 KiB
Markdown
# Events System
|
|
|
|
The events system allows mom to be triggered by scheduled or immediate events. Events are JSON files in the `workspace/events/` directory. The harness watches this directory and executes events when they become due.
|
|
|
|
## Event Types
|
|
|
|
### Immediate
|
|
|
|
Executes as soon as the harness discovers the file. Used by programs mom writes to signal external events (webhooks, file changes, API callbacks, etc.).
|
|
|
|
```json
|
|
{
|
|
"type": "immediate",
|
|
"channelId": "C123ABC",
|
|
"text": "New support ticket received: #12345"
|
|
}
|
|
```
|
|
|
|
After execution, the file is deleted. Staleness is determined by file mtime (see Startup Behavior).
|
|
|
|
### One-Shot
|
|
|
|
Executes once at a specific date/time. Used for reminders, scheduled tasks, or deferred actions.
|
|
|
|
```json
|
|
{
|
|
"type": "one-shot",
|
|
"channelId": "C123ABC",
|
|
"text": "Remind Mario about the dentist appointment",
|
|
"at": "2025-12-15T09:00:00+01:00"
|
|
}
|
|
```
|
|
|
|
The `at` timestamp must include a timezone offset. After execution, the file is deleted.
|
|
|
|
### Periodic
|
|
|
|
Executes repeatedly on a cron schedule. Used for recurring tasks like daily summaries, weekly reports, or regular checks.
|
|
|
|
```json
|
|
{
|
|
"type": "periodic",
|
|
"channelId": "C123ABC",
|
|
"text": "Check inbox and post summary",
|
|
"schedule": "0 9 * * 1-5",
|
|
"timezone": "Europe/Vienna"
|
|
}
|
|
```
|
|
|
|
The `schedule` field uses standard cron syntax. The `timezone` field uses IANA timezone names. The file persists until explicitly deleted by mom or the program that created it.
|
|
|
|
#### Cron Format
|
|
|
|
`minute hour day-of-month month day-of-week`
|
|
|
|
Examples:
|
|
- `0 9 * * *` — daily at 9:00
|
|
- `0 9 * * 1-5` — weekdays at 9:00
|
|
- `30 14 * * 1` — Mondays at 14:30
|
|
- `0 0 1 * *` — first of each month at midnight
|
|
- `*/15 * * * *` — every 15 minutes
|
|
|
|
## Timezone Handling
|
|
|
|
All timestamps must include timezone information:
|
|
- For `one-shot`: Use ISO 8601 format with offset (e.g., `2025-12-15T09:00:00+01:00`)
|
|
- For `periodic`: Use the `timezone` field with an IANA timezone name (e.g., `Europe/Vienna`, `America/New_York`)
|
|
|
|
The harness runs in the host process timezone. When users mention times without specifying timezone, assume the harness timezone.
|
|
|
|
## Harness Behavior
|
|
|
|
### Startup
|
|
|
|
1. Scan `workspace/events/` for all `.json` files
|
|
2. Parse each event file
|
|
3. For each event:
|
|
- **Immediate**: Check file mtime. If the file was created while the harness was NOT running (mtime < harness start time), it's stale. Delete without executing. Otherwise, execute immediately and delete.
|
|
- **One-shot**: If `at` is in the past, delete the file. If `at` is in the future, set a `setTimeout` to execute at the specified time.
|
|
- **Periodic**: Set up a cron job (using `croner` library) to execute on the specified schedule. If a scheduled time was missed while harness was down, do NOT catch up. Wait for the next scheduled occurrence.
|
|
|
|
### File System Watching
|
|
|
|
The harness watches `workspace/events/` using `fs.watch()` with 100ms debounce.
|
|
|
|
**New file added:**
|
|
- Parse the event
|
|
- Based on type: execute immediately, set `setTimeout`, or set up cron job
|
|
|
|
**Existing file modified:**
|
|
- Cancel any existing timer/cron for this file
|
|
- Re-parse and set up again (allows rescheduling)
|
|
|
|
**File deleted:**
|
|
- Cancel any existing timer/cron for this file
|
|
|
|
### Parse Errors
|
|
|
|
If a JSON file fails to parse:
|
|
1. Retry with exponential backoff (100ms, 200ms, 400ms)
|
|
2. If still failing after retries, delete the file and log error to console
|
|
|
|
### Execution Errors
|
|
|
|
If the agent errors while processing an event:
|
|
1. Post error message to the channel
|
|
2. Delete the event file (for immediate/one-shot)
|
|
3. No retries
|
|
|
|
## Queue Integration
|
|
|
|
Events integrate with the existing `ChannelQueue` in `SlackBot`:
|
|
|
|
- New method: `SlackBot.enqueueEvent(event: SlackEvent)` — always queues, no "already working" rejection
|
|
- Maximum 5 events can be queued per channel. If queue is full, discard and log to console.
|
|
- User @mom mentions retain current behavior: rejected with "Already working" message if agent is busy
|
|
|
|
When an event triggers:
|
|
1. Create a synthetic `SlackEvent` with formatted message
|
|
2. Call `slack.enqueueEvent(event)`
|
|
3. Event waits in queue if agent is busy, processed when idle
|
|
|
|
## Event Execution
|
|
|
|
When an event is dequeued and executes:
|
|
|
|
1. Post status message: "_Starting event: {filename}_"
|
|
2. Invoke the agent with message: `[EVENT:{filename}:{type}:{schedule}] {text}`
|
|
- For immediate: `[EVENT:webhook-123.json:immediate] New support ticket`
|
|
- For one-shot: `[EVENT:dentist.json:one-shot:2025-12-15T09:00:00+01:00] Remind Mario`
|
|
- For periodic: `[EVENT:daily-inbox.json:periodic:0 9 * * 1-5] Check inbox`
|
|
3. After execution:
|
|
- Immediate and one-shot: delete the event file
|
|
- Periodic: keep the file, event will trigger again on schedule
|
|
|
|
## File Naming
|
|
|
|
Event files should have descriptive names ending in `.json`:
|
|
- `webhook-12345.json` (immediate)
|
|
- `dentist-reminder-2025-12-15.json` (one-shot)
|
|
- `daily-inbox-summary.json` (periodic)
|
|
|
|
The filename is used as an identifier for tracking timers and in the event message. Avoid special characters.
|
|
|
|
## Implementation
|
|
|
|
### Files
|
|
|
|
- `src/events.ts` — Event parsing, timer management, fs watching
|
|
- `src/slack.ts` — Add `enqueueEvent()` method and `size()` to `ChannelQueue`
|
|
- `src/main.ts` — Initialize events watcher on startup
|
|
- `src/agent.ts` — Update system prompt with events documentation
|
|
|
|
### Key Components
|
|
|
|
```typescript
|
|
// events.ts
|
|
|
|
interface ImmediateEvent {
|
|
type: "immediate";
|
|
channelId: string;
|
|
text: string;
|
|
}
|
|
|
|
interface OneShotEvent {
|
|
type: "one-shot";
|
|
channelId: string;
|
|
text: string;
|
|
at: string; // ISO 8601 with timezone offset
|
|
}
|
|
|
|
interface PeriodicEvent {
|
|
type: "periodic";
|
|
channelId: string;
|
|
text: string;
|
|
schedule: string; // cron syntax
|
|
timezone: string; // IANA timezone
|
|
}
|
|
|
|
type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
|
|
|
|
class EventsWatcher {
|
|
private timers: Map<string, NodeJS.Timeout> = new Map();
|
|
private crons: Map<string, Cron> = new Map();
|
|
private startTime: number;
|
|
|
|
constructor(
|
|
private eventsDir: string,
|
|
private slack: SlackBot,
|
|
private onError: (filename: string, error: Error) => void
|
|
) {
|
|
this.startTime = Date.now();
|
|
}
|
|
|
|
start(): void { /* scan existing, setup fs.watch */ }
|
|
stop(): void { /* cancel all timers/crons, stop watching */ }
|
|
|
|
private handleFile(filename: string): void { /* parse, schedule */ }
|
|
private handleDelete(filename: string): void { /* cancel timer/cron */ }
|
|
private execute(filename: string, event: MomEvent): void { /* enqueue */ }
|
|
}
|
|
```
|
|
|
|
### Dependencies
|
|
|
|
- `croner` — Cron scheduling with timezone support
|
|
|
|
## System Prompt Section
|
|
|
|
The following should be added to mom's system prompt:
|
|
|
|
```markdown
|
|
## Events
|
|
|
|
You can schedule events that wake you up at specific times or when external things happen. Events are JSON files in `/workspace/events/`.
|
|
|
|
### Event Types
|
|
|
|
**Immediate** — Triggers as soon as harness sees the file. Use in scripts/webhooks to signal external events.
|
|
```json
|
|
{"type": "immediate", "channelId": "C123", "text": "New GitHub issue opened"}
|
|
```
|
|
|
|
**One-shot** — Triggers once at a specific time. Use for reminders.
|
|
```json
|
|
{"type": "one-shot", "channelId": "C123", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
|
|
```
|
|
|
|
**Periodic** — Triggers on a cron schedule. Use for recurring tasks.
|
|
```json
|
|
{"type": "periodic", "channelId": "C123", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "Europe/Vienna"}
|
|
```
|
|
|
|
### Cron Format
|
|
|
|
`minute hour day-of-month month day-of-week`
|
|
|
|
- `0 9 * * *` = daily at 9:00
|
|
- `0 9 * * 1-5` = weekdays at 9:00
|
|
- `30 14 * * 1` = Mondays at 14:30
|
|
- `0 0 1 * *` = first of each month at midnight
|
|
|
|
### Timezones
|
|
|
|
All `at` timestamps must include offset (e.g., `+01:00`). Periodic events use IANA timezone names. The harness runs in ${TIMEZONE}. When users mention times without timezone, assume ${TIMEZONE}.
|
|
|
|
### Creating Events
|
|
|
|
```bash
|
|
cat > /workspace/events/dentist-reminder.json << 'EOF'
|
|
{"type": "one-shot", "channelId": "${CHANNEL}", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
|
|
EOF
|
|
```
|
|
|
|
### Managing Events
|
|
|
|
- List: `ls /workspace/events/`
|
|
- View: `cat /workspace/events/foo.json`
|
|
- Delete/cancel: `rm /workspace/events/foo.json`
|
|
|
|
### When Events Trigger
|
|
|
|
You receive a message like:
|
|
```
|
|
[EVENT:dentist-reminder.json:one-shot:2025-12-14T09:00:00+01:00] Dentist tomorrow
|
|
```
|
|
|
|
Immediate and one-shot events auto-delete after triggering. Periodic events persist until you delete them.
|
|
|
|
### Debouncing
|
|
|
|
When writing programs that create immediate events (email watchers, webhook handlers, etc.), always debounce. If 50 emails arrive in a minute, don't create 50 immediate events. Instead:
|
|
|
|
- Collect events over a window (e.g., 30 seconds)
|
|
- Create ONE immediate event summarizing what happened
|
|
- Or just signal "new activity, check inbox" rather than per-item events
|
|
|
|
Bad:
|
|
```bash
|
|
# Creates event per email — will flood the queue
|
|
on_email() { echo '{"type":"immediate"...}' > /workspace/events/email-$ID.json; }
|
|
```
|
|
|
|
Good:
|
|
```bash
|
|
# Debounce: flag file + single delayed event
|
|
on_email() {
|
|
echo "$SUBJECT" >> /tmp/pending-emails.txt
|
|
if [ ! -f /workspace/events/email-batch.json ]; then
|
|
(sleep 30 && mv /tmp/pending-emails.txt /workspace/events/email-batch.json) &
|
|
fi
|
|
}
|
|
```
|
|
|
|
Or simpler: use a periodic event to check for new emails every 15 minutes instead of immediate events.
|
|
|
|
### Limits
|
|
|
|
Maximum 5 events can be queued. Don't create excessive immediate or periodic events.
|
|
```
|