discoclaw 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.context/memory.md +7 -4
- package/dist/cron/chain.js +5 -0
- package/dist/cron/chain.test.js +226 -0
- package/dist/cron/cron-prompt.js +26 -4
- package/dist/cron/cron-prompt.test.js +78 -0
- package/dist/cron/executor.js +74 -1
- package/dist/cron/executor.test.js +102 -0
- package/dist/cron/run-stats.js +12 -1
- package/dist/cron/run-stats.test.js +68 -6
- package/dist/discord/actions-crons.js +112 -2
- package/dist/discord/actions-crons.test.js +48 -0
- package/dist/discord/actions-memory.js +2 -2
- package/dist/discord/durable-memory.js +41 -8
- package/dist/discord/durable-memory.test.js +106 -7
- package/dist/discord/prompt-common.js +15 -1
- package/dist/discord/reaction-handler.test.js +1 -1
- package/dist/discord/user-turn-to-durable.js +2 -2
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/.context/memory.md
CHANGED
|
@@ -33,7 +33,10 @@ Bot: We've been working through your Express → Fastify migration.
|
|
|
33
33
|
|
|
34
34
|
Structured store of user facts. Each item has a kind (fact, preference, project,
|
|
35
35
|
constraint, person, tool, workflow), deduplication by content hash, and a 200-item
|
|
36
|
-
cap per user.
|
|
36
|
+
cap per user. Items track `hitCount` (incremented each time the item is selected
|
|
37
|
+
for prompt injection) and `lastHitAt` (timestamp of most recent selection).
|
|
38
|
+
Injected into every prompt using a blended score of recency and usage frequency
|
|
39
|
+
rather than raw `updatedAt` alone.
|
|
37
40
|
|
|
38
41
|
**What the user sees:**
|
|
39
42
|
- The bot knows your preferences, projects, and key facts across all conversations.
|
|
@@ -53,7 +56,7 @@ Bot: Given your preference for Rust in systems work, I'd lean that way —
|
|
|
53
56
|
|
|
54
57
|
#### Consolidation
|
|
55
58
|
|
|
56
|
-
When the active item count for a user crosses a threshold (`DISCOCLAW_DURABLE_CONSOLIDATION_THRESHOLD`, default `100`), consolidation can be triggered to prune and merge the list. A single `fast`-tier model call receives all active items and is asked to return a revised list — removing exact duplicates, merging near-duplicates, dropping clearly stale items, and preserving everything that is still plausibly useful. The model must not invent new facts or change the meaning of existing ones.
|
|
59
|
+
When the active item count for a user crosses a threshold (`DISCOCLAW_DURABLE_CONSOLIDATION_THRESHOLD`, default `100`), consolidation can be triggered to prune and merge the list. A single `fast`-tier model call receives all active items and is asked to return a revised list — removing exact duplicates, merging near-duplicates, dropping clearly stale items, and preserving everything that is still plausibly useful. The model must not invent new facts or change the meaning of existing ones. Items with low `hitCount` and stale `lastHitAt` are natural eviction candidates — the blended score surfaces which items the AI actually uses versus those that were added once and never referenced again.
|
|
57
60
|
|
|
58
61
|
The revised list is applied atomically: items absent from the model's output are deprecated via `deprecateItems()`; new or rewritten items are written via `addItem()`. Items present verbatim in the output are left untouched (no unnecessary writes).
|
|
59
62
|
|
|
@@ -183,7 +186,7 @@ no separator). The three memory builders run in `Promise.all` so they add no lat
|
|
|
183
186
|
|
|
184
187
|
| Layer | Default budget | Default state | How it stays within budget |
|
|
185
188
|
|-------|---------------|---------------|---------------------------|
|
|
186
|
-
| Durable memory | 2000 chars | on |
|
|
189
|
+
| Durable memory | 2000 chars | on | Ranks active items by blended score (recency + hit frequency), adds one at a time, stops when next line would exceed budget. Low-scoring items silently excluded. |
|
|
187
190
|
| Rolling summary | 2000 chars | on | The `fast`-tier model is prompted with `"Keep the summary under {maxChars} characters"`. Replaces itself each update rather than growing. |
|
|
188
191
|
| Message history | 3000 chars | on | Fetches up to 10 messages, walks backward from newest. Bot messages truncated to fit; user messages that don't fit cause a hard stop. |
|
|
189
192
|
| Short-term memory | 1000 chars | **on** | Filters by max age (default 6h), sorts newest-first, accumulates lines until budget hit. |
|
|
@@ -202,7 +205,7 @@ might add ~500 chars total. Sections with no data produce zero overhead.
|
|
|
202
205
|
|
|
203
206
|
### Where the budgets are enforced
|
|
204
207
|
|
|
205
|
-
- **Durable**: `selectItemsForInjection()` in `durable-memory.ts:152`
|
|
208
|
+
- **Durable**: `selectItemsForInjection()` in `durable-memory.ts:152` — scores items using `hitCount`, `lastHitAt`, and `updatedAt`; increments hit counters on selected items
|
|
206
209
|
- **Short-term**: `selectEntriesForInjection()` in `shortterm-memory.ts:113`
|
|
207
210
|
- **Summary**: `fast`-tier prompt constraint in `summarizer.ts:63`
|
|
208
211
|
- **History**: `fetchMessageHistory()` in `message-history.ts:38`
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { fireChainedJobs } from './executor.js';
|
|
6
|
+
import { loadRunStats } from './run-stats.js';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
function mockLog() {
|
|
11
|
+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
12
|
+
}
|
|
13
|
+
let tmpDir;
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chain-test-'));
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: 3 });
|
|
19
|
+
});
|
|
20
|
+
async function makeStatsStore() {
|
|
21
|
+
return loadRunStats(path.join(tmpDir, 'stats.json'));
|
|
22
|
+
}
|
|
23
|
+
function makeDownstreamJob(cronId, threadId) {
|
|
24
|
+
return {
|
|
25
|
+
id: threadId,
|
|
26
|
+
cronId,
|
|
27
|
+
threadId,
|
|
28
|
+
guildId: 'guild-1',
|
|
29
|
+
name: `Job ${cronId}`,
|
|
30
|
+
def: { triggerType: 'schedule', schedule: '0 0 * * *', timezone: 'UTC', channel: 'general', prompt: 'test' },
|
|
31
|
+
cron: null,
|
|
32
|
+
running: false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function makeMinimalCtx(statsStore, overrides) {
|
|
36
|
+
return {
|
|
37
|
+
client: {},
|
|
38
|
+
runtime: { id: 'claude_code', capabilities: new Set(), async *invoke() { } },
|
|
39
|
+
model: 'haiku',
|
|
40
|
+
cwd: '/tmp',
|
|
41
|
+
tools: [],
|
|
42
|
+
timeoutMs: 30_000,
|
|
43
|
+
status: null,
|
|
44
|
+
log: mockLog(),
|
|
45
|
+
discordActionsEnabled: false,
|
|
46
|
+
actionFlags: {
|
|
47
|
+
channels: false, messaging: false, guild: false, moderation: false,
|
|
48
|
+
polls: false, tasks: false, crons: false, botProfile: false,
|
|
49
|
+
forge: false, plan: false, memory: false, config: false, defer: false, voice: false,
|
|
50
|
+
},
|
|
51
|
+
statsStore,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// fireChainedJobs
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
describe('fireChainedJobs', () => {
|
|
59
|
+
it('does nothing when getSchedulerJob is not set', async () => {
|
|
60
|
+
const store = await makeStatsStore();
|
|
61
|
+
await store.upsertRecord('upstream', 'thread-up', { chain: ['downstream'] });
|
|
62
|
+
await store.upsertRecord('downstream', 'thread-down', {});
|
|
63
|
+
const ctx = makeMinimalCtx(store);
|
|
64
|
+
// No getSchedulerJob → early return
|
|
65
|
+
await fireChainedJobs('upstream', ctx);
|
|
66
|
+
const rec = store.getRecord('downstream');
|
|
67
|
+
// State should NOT have been forwarded
|
|
68
|
+
expect(rec?.state).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
it('does nothing when statsStore is not set', async () => {
|
|
71
|
+
const ctx = makeMinimalCtx(undefined, { statsStore: undefined });
|
|
72
|
+
// Should not throw
|
|
73
|
+
await fireChainedJobs('upstream', ctx);
|
|
74
|
+
});
|
|
75
|
+
it('does nothing when the upstream job has no chain', async () => {
|
|
76
|
+
const store = await makeStatsStore();
|
|
77
|
+
await store.upsertRecord('upstream', 'thread-up', {});
|
|
78
|
+
const getSchedulerJob = vi.fn();
|
|
79
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob });
|
|
80
|
+
await fireChainedJobs('upstream', ctx);
|
|
81
|
+
expect(getSchedulerJob).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
it('does nothing when chain is empty array', async () => {
|
|
84
|
+
const store = await makeStatsStore();
|
|
85
|
+
await store.upsertRecord('upstream', 'thread-up', { chain: [] });
|
|
86
|
+
const getSchedulerJob = vi.fn();
|
|
87
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob });
|
|
88
|
+
await fireChainedJobs('upstream', ctx);
|
|
89
|
+
expect(getSchedulerJob).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
it('forwards __upstream state to downstream job', async () => {
|
|
92
|
+
const store = await makeStatsStore();
|
|
93
|
+
const upstreamState = { lastSeenTag: 'v2.3.1', items: [1, 2, 3] };
|
|
94
|
+
await store.upsertRecord('upstream', 'thread-up', {
|
|
95
|
+
chain: ['downstream'],
|
|
96
|
+
state: upstreamState,
|
|
97
|
+
});
|
|
98
|
+
await store.upsertRecord('downstream', 'thread-down', {
|
|
99
|
+
state: { existingKey: 'preserved' },
|
|
100
|
+
});
|
|
101
|
+
const downstreamJob = makeDownstreamJob('downstream', 'thread-down');
|
|
102
|
+
const getSchedulerJob = vi.fn().mockReturnValue(downstreamJob);
|
|
103
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob });
|
|
104
|
+
await fireChainedJobs('upstream', ctx);
|
|
105
|
+
const rec = store.getRecord('downstream');
|
|
106
|
+
expect(rec?.state).toEqual({
|
|
107
|
+
existingKey: 'preserved',
|
|
108
|
+
__upstream: { fromCronId: 'upstream', state: upstreamState },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
it('forwards empty state as __upstream when upstream has no state', async () => {
|
|
112
|
+
const store = await makeStatsStore();
|
|
113
|
+
await store.upsertRecord('upstream', 'thread-up', { chain: ['downstream'] });
|
|
114
|
+
await store.upsertRecord('downstream', 'thread-down', {});
|
|
115
|
+
const downstreamJob = makeDownstreamJob('downstream', 'thread-down');
|
|
116
|
+
const getSchedulerJob = vi.fn().mockReturnValue(downstreamJob);
|
|
117
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob });
|
|
118
|
+
await fireChainedJobs('upstream', ctx);
|
|
119
|
+
const rec = store.getRecord('downstream');
|
|
120
|
+
expect(rec?.state).toEqual({
|
|
121
|
+
__upstream: { fromCronId: 'upstream', state: {} },
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
it('skips downstream when record is not found', async () => {
|
|
125
|
+
const store = await makeStatsStore();
|
|
126
|
+
await store.upsertRecord('upstream', 'thread-up', { chain: ['nonexistent'] });
|
|
127
|
+
const getSchedulerJob = vi.fn();
|
|
128
|
+
const log = mockLog();
|
|
129
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob, log });
|
|
130
|
+
await fireChainedJobs('upstream', ctx);
|
|
131
|
+
expect(getSchedulerJob).not.toHaveBeenCalled();
|
|
132
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ downstream: 'nonexistent' }), expect.stringContaining('record not found'));
|
|
133
|
+
});
|
|
134
|
+
it('skips downstream when scheduler job is not found', async () => {
|
|
135
|
+
const store = await makeStatsStore();
|
|
136
|
+
await store.upsertRecord('upstream', 'thread-up', { chain: ['downstream'] });
|
|
137
|
+
await store.upsertRecord('downstream', 'thread-down', {});
|
|
138
|
+
const getSchedulerJob = vi.fn().mockReturnValue(undefined);
|
|
139
|
+
const log = mockLog();
|
|
140
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob, log });
|
|
141
|
+
await fireChainedJobs('upstream', ctx);
|
|
142
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ downstream: 'downstream' }), expect.stringContaining('scheduler job not found'));
|
|
143
|
+
});
|
|
144
|
+
it('fires multiple downstream jobs independently', async () => {
|
|
145
|
+
const store = await makeStatsStore();
|
|
146
|
+
await store.upsertRecord('upstream', 'thread-up', {
|
|
147
|
+
chain: ['down-a', 'down-b'],
|
|
148
|
+
state: { data: 42 },
|
|
149
|
+
});
|
|
150
|
+
await store.upsertRecord('down-a', 'thread-a', {});
|
|
151
|
+
await store.upsertRecord('down-b', 'thread-b', {});
|
|
152
|
+
const jobA = makeDownstreamJob('down-a', 'thread-a');
|
|
153
|
+
const jobB = makeDownstreamJob('down-b', 'thread-b');
|
|
154
|
+
const getSchedulerJob = vi.fn((threadId) => {
|
|
155
|
+
if (threadId === 'thread-a')
|
|
156
|
+
return jobA;
|
|
157
|
+
if (threadId === 'thread-b')
|
|
158
|
+
return jobB;
|
|
159
|
+
return undefined;
|
|
160
|
+
});
|
|
161
|
+
const log = mockLog();
|
|
162
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob, log });
|
|
163
|
+
await fireChainedJobs('upstream', ctx);
|
|
164
|
+
// Both should have __upstream state forwarded
|
|
165
|
+
const recA = store.getRecord('down-a');
|
|
166
|
+
const recB = store.getRecord('down-b');
|
|
167
|
+
expect(recA?.state?.__upstream).toEqual({ fromCronId: 'upstream', state: { data: 42 } });
|
|
168
|
+
expect(recB?.state?.__upstream).toEqual({ fromCronId: 'upstream', state: { data: 42 } });
|
|
169
|
+
// Both should have been logged as fired
|
|
170
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ downstream: 'down-a' }), expect.stringContaining('downstream fired'));
|
|
171
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ downstream: 'down-b' }), expect.stringContaining('downstream fired'));
|
|
172
|
+
});
|
|
173
|
+
it('logs warning and skips downstream when chain depth >= 10', async () => {
|
|
174
|
+
const store = await makeStatsStore();
|
|
175
|
+
await store.upsertRecord('upstream', 'thread-up', { chain: ['downstream'] });
|
|
176
|
+
await store.upsertRecord('downstream', 'thread-down', {});
|
|
177
|
+
const getSchedulerJob = vi.fn();
|
|
178
|
+
const log = mockLog();
|
|
179
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob, log, chainDepth: 10 });
|
|
180
|
+
await fireChainedJobs('upstream', ctx);
|
|
181
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ cronId: 'upstream', chainDepth: 10 }), expect.stringContaining('depth limit'));
|
|
182
|
+
// getSchedulerJob should never be called — we returned early
|
|
183
|
+
expect(getSchedulerJob).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
it('increments chain depth for downstream execution context', async () => {
|
|
186
|
+
const store = await makeStatsStore();
|
|
187
|
+
await store.upsertRecord('upstream', 'thread-up', {
|
|
188
|
+
chain: ['downstream'],
|
|
189
|
+
state: { x: 1 },
|
|
190
|
+
});
|
|
191
|
+
await store.upsertRecord('downstream', 'thread-down', {});
|
|
192
|
+
const downstreamJob = makeDownstreamJob('downstream', 'thread-down');
|
|
193
|
+
const getSchedulerJob = vi.fn().mockReturnValue(downstreamJob);
|
|
194
|
+
const log = mockLog();
|
|
195
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob, log, chainDepth: 5 });
|
|
196
|
+
await fireChainedJobs('upstream', ctx);
|
|
197
|
+
// The function should have fired (depth 5 < 10)
|
|
198
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ downstream: 'downstream' }), expect.stringContaining('downstream fired'));
|
|
199
|
+
});
|
|
200
|
+
it('handles state forward failure gracefully', async () => {
|
|
201
|
+
const store = await makeStatsStore();
|
|
202
|
+
await store.upsertRecord('upstream', 'thread-up', {
|
|
203
|
+
chain: ['downstream'],
|
|
204
|
+
state: { data: 1 },
|
|
205
|
+
});
|
|
206
|
+
await store.upsertRecord('downstream', 'thread-down', {});
|
|
207
|
+
// Mock upsertRecord to fail on state forwarding
|
|
208
|
+
const originalUpsert = store.upsertRecord.bind(store);
|
|
209
|
+
let callCount = 0;
|
|
210
|
+
vi.spyOn(store, 'upsertRecord').mockImplementation(async (...args) => {
|
|
211
|
+
callCount++;
|
|
212
|
+
// Fail on the state-forwarding call (the one during fireChainedJobs)
|
|
213
|
+
if (callCount > 0)
|
|
214
|
+
throw new Error('disk full');
|
|
215
|
+
return originalUpsert(...args);
|
|
216
|
+
});
|
|
217
|
+
const downstreamJob = makeDownstreamJob('downstream', 'thread-down');
|
|
218
|
+
const getSchedulerJob = vi.fn().mockReturnValue(downstreamJob);
|
|
219
|
+
const log = mockLog();
|
|
220
|
+
const ctx = makeMinimalCtx(store, { getSchedulerJob, log });
|
|
221
|
+
await fireChainedJobs('upstream', ctx);
|
|
222
|
+
// Should log a warning but still fire downstream
|
|
223
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ downstream: 'downstream' }), expect.stringContaining('state forward failed'));
|
|
224
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ downstream: 'downstream' }), expect.stringContaining('downstream fired'));
|
|
225
|
+
});
|
|
226
|
+
});
|
package/dist/cron/cron-prompt.js
CHANGED
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
* Expand {{channel}} and {{channelId}} placeholders in a cron prompt template.
|
|
13
13
|
* All occurrences are replaced; unrecognized placeholders are left intact.
|
|
14
14
|
*/
|
|
15
|
-
export function expandCronPlaceholders(text, channel, channelId) {
|
|
16
|
-
|
|
15
|
+
export function expandCronPlaceholders(text, channel, channelId, state) {
|
|
16
|
+
const stateJson = JSON.stringify(state ?? {});
|
|
17
|
+
return text
|
|
18
|
+
.replaceAll('{{channel}}', channel)
|
|
19
|
+
.replaceAll('{{channelId}}', channelId)
|
|
20
|
+
.replaceAll('{{state}}', stateJson);
|
|
17
21
|
}
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
23
|
// Prompt body builder
|
|
@@ -29,8 +33,8 @@ export function expandCronPlaceholders(text, channel, channelId) {
|
|
|
29
33
|
* - Silent-mode sentinel instruction
|
|
30
34
|
*/
|
|
31
35
|
export function buildCronPromptBody(input) {
|
|
32
|
-
const { jobName, promptTemplate, channel, channelId = '', silent, routingMode, availableChannels, } = input;
|
|
33
|
-
const expandedPrompt = expandCronPlaceholders(promptTemplate, channel, channelId);
|
|
36
|
+
const { jobName, promptTemplate, channel, channelId = '', silent, routingMode, availableChannels, state, } = input;
|
|
37
|
+
const expandedPrompt = expandCronPlaceholders(promptTemplate, channel, channelId, state);
|
|
34
38
|
const segments = [
|
|
35
39
|
`You are executing a scheduled cron job named "${jobName}".`,
|
|
36
40
|
`Instruction: ${expandedPrompt}`,
|
|
@@ -49,6 +53,24 @@ export function buildCronPromptBody(input) {
|
|
|
49
53
|
segments.push('IMPORTANT: If there is nothing actionable to report, respond with exactly `HEARTBEAT_OK` and nothing else.');
|
|
50
54
|
}
|
|
51
55
|
}
|
|
56
|
+
// Inject persistent state section when state is present and non-empty.
|
|
57
|
+
if (state && Object.keys(state).length > 0) {
|
|
58
|
+
const STATE_CHAR_LIMIT = 4000;
|
|
59
|
+
let serialized = JSON.stringify(state, null, 2);
|
|
60
|
+
if (serialized.length > STATE_CHAR_LIMIT) {
|
|
61
|
+
serialized = serialized.slice(0, STATE_CHAR_LIMIT) + '\n... (state truncated)';
|
|
62
|
+
}
|
|
63
|
+
segments.push([
|
|
64
|
+
'## Persistent State',
|
|
65
|
+
'',
|
|
66
|
+
'The following state was persisted from your previous run:',
|
|
67
|
+
'```json',
|
|
68
|
+
serialized,
|
|
69
|
+
'```',
|
|
70
|
+
'If you need to update the persisted state for the next run, emit a `<cron-state>{...}</cron-state>` block ' +
|
|
71
|
+
'containing a JSON object with the full updated state. The emitted object fully replaces the existing state — include all keys you want to keep. Only emit this block if the state needs to change.',
|
|
72
|
+
].join('\n'));
|
|
73
|
+
}
|
|
52
74
|
return segments.join('\n\n');
|
|
53
75
|
}
|
|
54
76
|
// ---------------------------------------------------------------------------
|
|
@@ -242,3 +242,81 @@ describe('buildCronPromptBody — placeholder expansion', () => {
|
|
|
242
242
|
expect(body).toContain('Channel alerts with ID ch-5.');
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// expandCronPlaceholders — {{state}} placeholder
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
describe('expandCronPlaceholders — {{state}} placeholder', () => {
|
|
249
|
+
it('expands {{state}} to JSON string of provided state', () => {
|
|
250
|
+
const result = expandCronPlaceholders('Current state: {{state}}', 'general', 'ch-1', { counter: 3, lastSeen: '2026-02-28' });
|
|
251
|
+
expect(result).toContain('"counter":3');
|
|
252
|
+
expect(result).toContain('"lastSeen":"2026-02-28"');
|
|
253
|
+
expect(result).not.toContain('{{state}}');
|
|
254
|
+
});
|
|
255
|
+
it('expands {{state}} to empty object JSON when state is undefined', () => {
|
|
256
|
+
const result = expandCronPlaceholders('State: {{state}}', 'general', 'ch-1');
|
|
257
|
+
expect(result).toBe('State: {}');
|
|
258
|
+
});
|
|
259
|
+
it('expands {{state}} to empty object JSON when state is empty', () => {
|
|
260
|
+
const result = expandCronPlaceholders('State: {{state}}', 'general', 'ch-1', {});
|
|
261
|
+
expect(result).toBe('State: {}');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// buildCronPromptBody — persistent state
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
describe('buildCronPromptBody — persistent state', () => {
|
|
268
|
+
it('includes Persistent State section when state is non-empty', () => {
|
|
269
|
+
const body = buildCronPromptBody({
|
|
270
|
+
jobName: 'Stateful Job',
|
|
271
|
+
promptTemplate: 'Check for updates.',
|
|
272
|
+
channel: 'general',
|
|
273
|
+
state: { lastCheck: '2026-02-27', items: [1, 2, 3] },
|
|
274
|
+
});
|
|
275
|
+
expect(body).toContain('## Persistent State');
|
|
276
|
+
expect(body).toContain('"lastCheck": "2026-02-27"');
|
|
277
|
+
expect(body).toContain('<cron-state>');
|
|
278
|
+
});
|
|
279
|
+
it('omits Persistent State section when state is undefined', () => {
|
|
280
|
+
const body = buildCronPromptBody({
|
|
281
|
+
jobName: 'Stateless Job',
|
|
282
|
+
promptTemplate: 'Say hello.',
|
|
283
|
+
channel: 'general',
|
|
284
|
+
});
|
|
285
|
+
expect(body).not.toContain('Persistent State');
|
|
286
|
+
expect(body).not.toContain('<cron-state>');
|
|
287
|
+
});
|
|
288
|
+
it('omits Persistent State section when state is empty object', () => {
|
|
289
|
+
const body = buildCronPromptBody({
|
|
290
|
+
jobName: 'Empty State Job',
|
|
291
|
+
promptTemplate: 'Say hello.',
|
|
292
|
+
channel: 'general',
|
|
293
|
+
state: {},
|
|
294
|
+
});
|
|
295
|
+
expect(body).not.toContain('Persistent State');
|
|
296
|
+
expect(body).not.toContain('<cron-state>');
|
|
297
|
+
});
|
|
298
|
+
it('truncates very large state objects', () => {
|
|
299
|
+
const largeState = {};
|
|
300
|
+
for (let i = 0; i < 500; i++) {
|
|
301
|
+
largeState[`key_${i}`] = 'x'.repeat(20);
|
|
302
|
+
}
|
|
303
|
+
const body = buildCronPromptBody({
|
|
304
|
+
jobName: 'Large State Job',
|
|
305
|
+
promptTemplate: 'Process data.',
|
|
306
|
+
channel: 'general',
|
|
307
|
+
state: largeState,
|
|
308
|
+
});
|
|
309
|
+
expect(body).toContain('## Persistent State');
|
|
310
|
+
expect(body).toContain('(state truncated)');
|
|
311
|
+
});
|
|
312
|
+
it('includes cron-state emit instruction in the state section', () => {
|
|
313
|
+
const body = buildCronPromptBody({
|
|
314
|
+
jobName: 'Job',
|
|
315
|
+
promptTemplate: 'Do stuff.',
|
|
316
|
+
channel: 'general',
|
|
317
|
+
state: { v: 1 },
|
|
318
|
+
});
|
|
319
|
+
expect(body).toContain('<cron-state>');
|
|
320
|
+
expect(body).toContain('update');
|
|
321
|
+
});
|
|
322
|
+
});
|
package/dist/cron/executor.js
CHANGED
|
@@ -20,6 +20,50 @@ async function recordError(ctx, job, msg) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
const MAX_CHAIN_DEPTH = 10;
|
|
24
|
+
export async function fireChainedJobs(cronId, ctx) {
|
|
25
|
+
const chainDepth = ctx.chainDepth ?? 0;
|
|
26
|
+
if (chainDepth >= MAX_CHAIN_DEPTH) {
|
|
27
|
+
ctx.log?.warn({ cronId, chainDepth }, 'chain:depth limit reached, skipping downstream');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!ctx.statsStore || !ctx.getSchedulerJob)
|
|
31
|
+
return;
|
|
32
|
+
const record = ctx.statsStore.getRecord(cronId);
|
|
33
|
+
if (!record?.chain || record.chain.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
const upstreamState = record.state;
|
|
36
|
+
for (const downstreamCronId of record.chain) {
|
|
37
|
+
const downstreamRecord = ctx.statsStore.getRecord(downstreamCronId);
|
|
38
|
+
if (!downstreamRecord) {
|
|
39
|
+
ctx.log?.warn({ cronId, downstream: downstreamCronId }, 'chain:downstream record not found, skipping');
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const downstreamJob = ctx.getSchedulerJob(downstreamRecord.threadId);
|
|
43
|
+
if (!downstreamJob) {
|
|
44
|
+
ctx.log?.warn({ cronId, downstream: downstreamCronId, threadId: downstreamRecord.threadId }, 'chain:downstream scheduler job not found, skipping');
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Merge __upstream into downstream job's persisted state so the prompt's {{state}} includes handoff data.
|
|
48
|
+
try {
|
|
49
|
+
const forwardedState = {
|
|
50
|
+
...(downstreamRecord.state ?? {}),
|
|
51
|
+
__upstream: { fromCronId: cronId, state: upstreamState ?? {} },
|
|
52
|
+
};
|
|
53
|
+
await ctx.statsStore.upsertRecord(downstreamCronId, downstreamRecord.threadId, { state: forwardedState });
|
|
54
|
+
ctx.log?.info({ cronId, downstream: downstreamCronId }, 'chain:state forwarded');
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
ctx.log?.warn({ err, cronId, downstream: downstreamCronId }, 'chain:state forward failed');
|
|
58
|
+
}
|
|
59
|
+
// Fire-and-forget with incremented chain depth.
|
|
60
|
+
const downstreamCtx = { ...ctx, chainDepth: chainDepth + 1 };
|
|
61
|
+
void executeCronJob(downstreamJob, downstreamCtx).catch((err) => {
|
|
62
|
+
ctx.log?.warn({ err, cronId, downstream: downstreamCronId }, 'chain:downstream execution failed');
|
|
63
|
+
});
|
|
64
|
+
ctx.log?.info({ cronId, downstream: downstreamCronId }, 'chain:downstream fired');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
23
67
|
export async function executeCronJob(job, ctx) {
|
|
24
68
|
const metrics = globalMetrics;
|
|
25
69
|
let cancelRequested = false;
|
|
@@ -110,6 +154,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
110
154
|
channelId: channelForSend.id,
|
|
111
155
|
silent: preRunRecord?.silent,
|
|
112
156
|
routingMode: preRunRecord?.routingMode === 'json' ? 'json' : undefined,
|
|
157
|
+
state: preRunRecord?.state,
|
|
113
158
|
});
|
|
114
159
|
const tools = await resolveEffectiveTools({
|
|
115
160
|
workspaceCwd: ctx.cwd,
|
|
@@ -208,7 +253,31 @@ export async function executeCronJob(job, ctx) {
|
|
|
208
253
|
}
|
|
209
254
|
metrics.recordInvokeResult('cron', Date.now() - t0, true);
|
|
210
255
|
ctx.log?.info({ flow: 'cron', jobId: job.id, ms: Date.now() - t0, ok: true }, 'obs.invoke.end');
|
|
211
|
-
|
|
256
|
+
let output = finalText || deltaText;
|
|
257
|
+
// Extract <cron-state> blocks from the output — last one wins.
|
|
258
|
+
const cronStateRegex = /<cron-state>([\s\S]*?)<\/cron-state>/g;
|
|
259
|
+
let cronStateMatch;
|
|
260
|
+
let lastCronStateJson;
|
|
261
|
+
while ((cronStateMatch = cronStateRegex.exec(output)) !== null) {
|
|
262
|
+
lastCronStateJson = cronStateMatch[1];
|
|
263
|
+
}
|
|
264
|
+
if (lastCronStateJson !== undefined && ctx.statsStore && job.cronId) {
|
|
265
|
+
try {
|
|
266
|
+
const parsedState = JSON.parse(lastCronStateJson.trim());
|
|
267
|
+
if (parsedState && typeof parsedState === 'object' && !Array.isArray(parsedState)) {
|
|
268
|
+
await ctx.statsStore.upsertRecord(job.cronId, job.threadId, { state: parsedState });
|
|
269
|
+
ctx.log?.info({ jobId: job.id, cronId: job.cronId }, 'cron:exec persisted updated state');
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
ctx.log?.warn({ jobId: job.id, cronId: job.cronId }, 'cron:exec <cron-state> was not a JSON object, ignoring');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch (stateErr) {
|
|
276
|
+
ctx.log?.warn({ err: stateErr, jobId: job.id, cronId: job.cronId }, 'cron:exec <cron-state> parse failed, ignoring');
|
|
277
|
+
}
|
|
278
|
+
// Strip all <cron-state> blocks from the output text.
|
|
279
|
+
output = output.replace(/<cron-state>[\s\S]*?<\/cron-state>/g, '').trim();
|
|
280
|
+
}
|
|
212
281
|
if (!output.trim() && collectedImages.length === 0) {
|
|
213
282
|
metrics.increment('cron.run.skipped');
|
|
214
283
|
ctx.log?.warn({ jobId: job.id }, 'cron:exec empty output');
|
|
@@ -219,6 +288,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
219
288
|
catch {
|
|
220
289
|
// Best-effort.
|
|
221
290
|
}
|
|
291
|
+
void fireChainedJobs(job.cronId, ctx);
|
|
222
292
|
}
|
|
223
293
|
return;
|
|
224
294
|
}
|
|
@@ -310,6 +380,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
310
380
|
catch {
|
|
311
381
|
// Best-effort.
|
|
312
382
|
}
|
|
383
|
+
void fireChainedJobs(job.cronId, ctx);
|
|
313
384
|
}
|
|
314
385
|
metrics.increment('cron.run.success');
|
|
315
386
|
return;
|
|
@@ -326,6 +397,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
326
397
|
catch {
|
|
327
398
|
// Best-effort.
|
|
328
399
|
}
|
|
400
|
+
void fireChainedJobs(job.cronId, ctx);
|
|
329
401
|
}
|
|
330
402
|
metrics.increment('cron.run.success');
|
|
331
403
|
return;
|
|
@@ -366,6 +438,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
366
438
|
catch (statsErr) {
|
|
367
439
|
ctx.log?.warn({ err: statsErr, jobId: job.id }, 'cron:exec stats record failed');
|
|
368
440
|
}
|
|
441
|
+
void fireChainedJobs(job.cronId, ctx);
|
|
369
442
|
}
|
|
370
443
|
}
|
|
371
444
|
catch (err) {
|
|
@@ -1003,3 +1003,105 @@ describe('executeCronJob allowedActions filtering', () => {
|
|
|
1003
1003
|
executeDiscordActionsSpy.mockRestore();
|
|
1004
1004
|
});
|
|
1005
1005
|
});
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
// <cron-state> extraction and persistence
|
|
1008
|
+
// ---------------------------------------------------------------------------
|
|
1009
|
+
describe('executeCronJob cron-state extraction', () => {
|
|
1010
|
+
let statsDir;
|
|
1011
|
+
beforeEach(async () => {
|
|
1012
|
+
statsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'executor-cron-state-'));
|
|
1013
|
+
});
|
|
1014
|
+
afterEach(async () => {
|
|
1015
|
+
await fs.rm(statsDir, { recursive: true, force: true });
|
|
1016
|
+
});
|
|
1017
|
+
it('extracts <cron-state> block and persists state via upsertRecord', async () => {
|
|
1018
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
1019
|
+
const statsStore = await loadRunStats(statsPath);
|
|
1020
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1');
|
|
1021
|
+
const response = 'Here is the report.\n<cron-state>{"lastSeen":"2026-02-28","count":5}</cron-state>';
|
|
1022
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(response) });
|
|
1023
|
+
const job = makeJob();
|
|
1024
|
+
await executeCronJob(job, ctx);
|
|
1025
|
+
const rec = statsStore.getRecord('cron-test0001');
|
|
1026
|
+
expect(rec.state).toEqual({ lastSeen: '2026-02-28', count: 5 });
|
|
1027
|
+
});
|
|
1028
|
+
it('strips <cron-state> blocks from the output sent to Discord', async () => {
|
|
1029
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
1030
|
+
const statsStore = await loadRunStats(statsPath);
|
|
1031
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1');
|
|
1032
|
+
const response = 'Report content here.\n<cron-state>{"v":1}</cron-state>';
|
|
1033
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(response) });
|
|
1034
|
+
const job = makeJob();
|
|
1035
|
+
await executeCronJob(job, ctx);
|
|
1036
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
1037
|
+
const channel = guild.channels.cache.get('general');
|
|
1038
|
+
expect(channel.send).toHaveBeenCalled();
|
|
1039
|
+
const sentContent = channel.send.mock.calls[0][0].content;
|
|
1040
|
+
expect(sentContent).not.toContain('<cron-state>');
|
|
1041
|
+
expect(sentContent).toContain('Report content here.');
|
|
1042
|
+
});
|
|
1043
|
+
it('uses the last <cron-state> block when multiple are present', async () => {
|
|
1044
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
1045
|
+
const statsStore = await loadRunStats(statsPath);
|
|
1046
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1');
|
|
1047
|
+
const response = [
|
|
1048
|
+
'Part 1.',
|
|
1049
|
+
'<cron-state>{"v":1}</cron-state>',
|
|
1050
|
+
'Part 2.',
|
|
1051
|
+
'<cron-state>{"v":2,"final":true}</cron-state>',
|
|
1052
|
+
].join('\n');
|
|
1053
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(response) });
|
|
1054
|
+
const job = makeJob();
|
|
1055
|
+
await executeCronJob(job, ctx);
|
|
1056
|
+
const rec = statsStore.getRecord('cron-test0001');
|
|
1057
|
+
expect(rec.state).toEqual({ v: 2, final: true });
|
|
1058
|
+
});
|
|
1059
|
+
it('ignores invalid JSON in <cron-state> block gracefully', async () => {
|
|
1060
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
1061
|
+
const statsStore = await loadRunStats(statsPath);
|
|
1062
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', { state: { old: true } });
|
|
1063
|
+
const response = 'Output here.\n<cron-state>not valid json</cron-state>';
|
|
1064
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(response) });
|
|
1065
|
+
const job = makeJob();
|
|
1066
|
+
await executeCronJob(job, ctx);
|
|
1067
|
+
// State should remain unchanged after invalid parse.
|
|
1068
|
+
const rec = statsStore.getRecord('cron-test0001');
|
|
1069
|
+
expect(rec.state).toEqual({ old: true });
|
|
1070
|
+
expect(ctx.log?.warn).toHaveBeenCalledWith(expect.objectContaining({ jobId: 'thread-1' }), 'cron:exec <cron-state> parse failed, ignoring');
|
|
1071
|
+
});
|
|
1072
|
+
it('ignores non-object JSON in <cron-state> block (e.g. array)', async () => {
|
|
1073
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
1074
|
+
const statsStore = await loadRunStats(statsPath);
|
|
1075
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1');
|
|
1076
|
+
const response = 'Output here.\n<cron-state>[1,2,3]</cron-state>';
|
|
1077
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(response) });
|
|
1078
|
+
const job = makeJob();
|
|
1079
|
+
await executeCronJob(job, ctx);
|
|
1080
|
+
const rec = statsStore.getRecord('cron-test0001');
|
|
1081
|
+
expect(rec.state).toBeUndefined();
|
|
1082
|
+
expect(ctx.log?.warn).toHaveBeenCalledWith(expect.objectContaining({ jobId: 'thread-1' }), 'cron:exec <cron-state> was not a JSON object, ignoring');
|
|
1083
|
+
});
|
|
1084
|
+
it('passes existing state to the prompt via preRunRecord', async () => {
|
|
1085
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
1086
|
+
const statsStore = await loadRunStats(statsPath);
|
|
1087
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', {
|
|
1088
|
+
state: { counter: 42, lastItem: 'xyz' },
|
|
1089
|
+
});
|
|
1090
|
+
let capturedPrompt = '';
|
|
1091
|
+
const runtime = {
|
|
1092
|
+
id: 'claude_code',
|
|
1093
|
+
capabilities: new Set(['streaming_text']),
|
|
1094
|
+
async *invoke(opts) {
|
|
1095
|
+
capturedPrompt = opts.prompt;
|
|
1096
|
+
yield { type: 'text_final', text: 'Done.' };
|
|
1097
|
+
yield { type: 'done' };
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
const ctx = makeCtx({ statsStore, runtime });
|
|
1101
|
+
const job = makeJob();
|
|
1102
|
+
await executeCronJob(job, ctx);
|
|
1103
|
+
expect(capturedPrompt).toContain('Persistent State');
|
|
1104
|
+
expect(capturedPrompt).toContain('"counter": 42');
|
|
1105
|
+
expect(capturedPrompt).toContain('"lastItem": "xyz"');
|
|
1106
|
+
});
|
|
1107
|
+
});
|