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.
@@ -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. Injected into every prompt.
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 | Sorts active items by recency, adds one at a time, stops when next line would exceed budget. Older facts silently excluded. |
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,5 @@
1
+ "use strict";
2
+ // This file has been intentionally removed.
3
+ // Chain logic lives directly in executor.ts (fireChainedJobs) and
4
+ // actions-crons.ts (parseAndValidateChain, detectChainCycle).
5
+ // See: ws-1077 audit fix.
@@ -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
+ });
@@ -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
- return text.replaceAll('{{channel}}', channel).replaceAll('{{channelId}}', channelId);
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
+ });
@@ -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
- const output = finalText || deltaText;
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
+ });