discoclaw 0.2.2 → 0.2.5
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/pa.md +1 -1
- package/dist/cli/daemon-installer.js +1 -0
- package/dist/cli/daemon-installer.test.js +6 -0
- package/dist/cron/executor.js +36 -1
- package/dist/cron/executor.test.js +157 -0
- package/dist/cron/forum-sync.js +47 -0
- package/dist/cron/forum-sync.test.js +234 -0
- package/dist/cron/run-stats.js +10 -3
- package/dist/cron/run-stats.test.js +67 -3
- package/dist/discord/actions-crons.js +17 -1
- package/dist/discord/actions-crons.test.js +42 -0
- package/dist/discord/user-errors.js +3 -0
- package/dist/discord/user-errors.test.js +8 -0
- package/dist/runtime/cli-adapter.js +24 -1
- package/dist/runtime/long-running-process.js +14 -2
- package/dist/runtime/long-running-process.test.js +17 -0
- package/dist/runtime/process-pool.js +2 -2
- package/dist/workspace-bootstrap.js +0 -1
- package/dist/workspace-bootstrap.test.js +0 -2
- package/package.json +1 -1
- package/systemd/discoclaw.service +1 -0
- package/templates/workspace/HEARTBEAT.md +0 -10
package/.context/pa.md
CHANGED
|
@@ -16,7 +16,6 @@ For architecture details, see `.context/architecture.md`.
|
|
|
16
16
|
| `USER.md` | Who you're helping | Every prompt |
|
|
17
17
|
| `AGENTS.md` | Your personal rules and conventions | Every prompt |
|
|
18
18
|
| `TOOLS.md` | Available tools and integrations | Every prompt |
|
|
19
|
-
| `HEARTBEAT.md` | Periodic self-check template | By cron |
|
|
20
19
|
| `MEMORY.md` | Curated long-term memory | DM prompts |
|
|
21
20
|
| `BOOTSTRAP.md` | First-run onboarding (deleted after) | Once |
|
|
22
21
|
|
|
@@ -27,6 +26,7 @@ Templates live in `templates/workspace/` and are scaffolded on first run (copy-i
|
|
|
27
26
|
- **Never go silent.** Acknowledge before tool calls.
|
|
28
27
|
- Narrate failures and pivots.
|
|
29
28
|
- Summarize outcomes; don't assume the user saw tool output.
|
|
29
|
+
- **Never edit `tasks.jsonl`, cron store files, or other data files directly.** Always use the corresponding discord action (`taskUpdate`, `taskCreate`, `cronUpdate`, etc.). Direct file edits bypass Discord thread sync and leave the UI stale.
|
|
30
30
|
|
|
31
31
|
## Discord Formatting
|
|
32
32
|
|
|
@@ -68,6 +68,7 @@ export function renderSystemdUnit(packageRoot, cwd) {
|
|
|
68
68
|
'',
|
|
69
69
|
'[Service]',
|
|
70
70
|
'Type=simple',
|
|
71
|
+
'Environment=PATH=%h/.local/bin:%h/.npm-global/bin:/usr/local/bin:/usr/bin:/bin',
|
|
71
72
|
`ExecStart=/usr/bin/node ${entryPoint}`,
|
|
72
73
|
`WorkingDirectory=${cwd}`,
|
|
73
74
|
`EnvironmentFile=${path.join(cwd, '.env')}`,
|
|
@@ -76,6 +76,12 @@ describe('renderSystemdUnit', () => {
|
|
|
76
76
|
const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
|
|
77
77
|
expect(unit).toContain(`EnvironmentFile=${CWD}/.env`);
|
|
78
78
|
});
|
|
79
|
+
it('includes Environment=PATH with user-local bin directories', () => {
|
|
80
|
+
const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
|
|
81
|
+
expect(unit).toContain('Environment=PATH=');
|
|
82
|
+
expect(unit).toContain('%h/.local/bin');
|
|
83
|
+
expect(unit).toContain('%h/.npm-global/bin');
|
|
84
|
+
});
|
|
79
85
|
it('includes standard unit and install sections', () => {
|
|
80
86
|
const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
|
|
81
87
|
expect(unit).toContain('[Unit]');
|
package/dist/cron/executor.js
CHANGED
|
@@ -124,6 +124,10 @@ export async function executeCronJob(job, ctx) {
|
|
|
124
124
|
if (preRunRecord) {
|
|
125
125
|
effectiveModel = preRunRecord.modelOverride ?? preRunRecord.model ?? cronDefault;
|
|
126
126
|
}
|
|
127
|
+
// Silent mode: instruct the AI to respond with HEARTBEAT_OK when idle.
|
|
128
|
+
if (preRunRecord?.silent) {
|
|
129
|
+
prompt += '\n\nIMPORTANT: If there is nothing actionable to report, respond with exactly `HEARTBEAT_OK` and nothing else.';
|
|
130
|
+
}
|
|
127
131
|
ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel, model: effectiveModel, permissionTier: tools.permissionTier }, 'cron:exec start');
|
|
128
132
|
// Best-effort: update pinned status message to show running indicator.
|
|
129
133
|
if (preRunRecord && job.cronId) {
|
|
@@ -269,13 +273,44 @@ export async function executeCronJob(job, ctx) {
|
|
|
269
273
|
}
|
|
270
274
|
processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
|
|
271
275
|
processedText = appendParseFailureNotice(processedText, parseFailuresCount);
|
|
276
|
+
// Suppress sentinel outputs (e.g. crons whose prompts say "output nothing if idle").
|
|
277
|
+
// Mirrors the reaction handler's logic at reaction-handler.ts:662-674.
|
|
278
|
+
const strippedText = processedText.replace(/\s+/g, ' ').trim();
|
|
279
|
+
const isSuppressible = strippedText === 'HEARTBEAT_OK' || strippedText === '(no output)';
|
|
280
|
+
if (isSuppressible && collectedImages.length === 0) {
|
|
281
|
+
ctx.log?.info({ jobId: job.id, name: job.name, sentinel: strippedText }, 'cron:exec sentinel output suppressed');
|
|
282
|
+
if (ctx.statsStore && job.cronId) {
|
|
283
|
+
try {
|
|
284
|
+
await ctx.statsStore.recordRun(job.cronId, 'success');
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Best-effort.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
metrics.increment('cron.run.success');
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Silent-mode short-response gate: suppress paraphrased "nothing to report" responses.
|
|
294
|
+
if (preRunRecord?.silent && collectedImages.length === 0 && strippedText.length <= 80) {
|
|
295
|
+
ctx.log?.info({ jobId: job.id, name: job.name, len: strippedText.length }, 'cron:exec silent short-response suppressed');
|
|
296
|
+
if (ctx.statsStore && job.cronId) {
|
|
297
|
+
try {
|
|
298
|
+
await ctx.statsStore.recordRun(job.cronId, 'success');
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Best-effort.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
metrics.increment('cron.run.success');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
272
307
|
await sendChunks(channelForSend, processedText, collectedImages);
|
|
273
308
|
ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel }, 'cron:exec done');
|
|
309
|
+
metrics.increment('cron.run.success');
|
|
274
310
|
// Record successful run.
|
|
275
311
|
if (ctx.statsStore && job.cronId) {
|
|
276
312
|
try {
|
|
277
313
|
await ctx.statsStore.recordRun(job.cronId, 'success');
|
|
278
|
-
metrics.increment('cron.run.success');
|
|
279
314
|
}
|
|
280
315
|
catch (statsErr) {
|
|
281
316
|
ctx.log?.warn({ err: statsErr, jobId: job.id }, 'cron:exec stats record failed');
|
|
@@ -324,6 +324,55 @@ describe('executeCronJob', () => {
|
|
|
324
324
|
expect(subsArg).toMatchObject({ imagegenCtx });
|
|
325
325
|
executeDiscordActionsSpy.mockRestore();
|
|
326
326
|
});
|
|
327
|
+
it('suppresses HEARTBEAT_OK output', async () => {
|
|
328
|
+
const ctx = makeCtx({ runtime: makeMockRuntime('HEARTBEAT_OK') });
|
|
329
|
+
const job = makeJob();
|
|
330
|
+
await executeCronJob(job, ctx);
|
|
331
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
332
|
+
const channel = guild.channels.cache.get('general');
|
|
333
|
+
expect(channel.send).not.toHaveBeenCalled();
|
|
334
|
+
expect(ctx.log?.info).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, sentinel: 'HEARTBEAT_OK' }), 'cron:exec sentinel output suppressed');
|
|
335
|
+
});
|
|
336
|
+
it('suppresses (no output) output', async () => {
|
|
337
|
+
const ctx = makeCtx({ runtime: makeMockRuntime('(no output)') });
|
|
338
|
+
const job = makeJob();
|
|
339
|
+
await executeCronJob(job, ctx);
|
|
340
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
341
|
+
const channel = guild.channels.cache.get('general');
|
|
342
|
+
expect(channel.send).not.toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
it('does not suppress HEARTBEAT_OK when images are present', async () => {
|
|
345
|
+
const runtime = {
|
|
346
|
+
id: 'claude_code',
|
|
347
|
+
capabilities: new Set(['streaming_text']),
|
|
348
|
+
async *invoke() {
|
|
349
|
+
yield { type: 'text_final', text: 'HEARTBEAT_OK' };
|
|
350
|
+
yield { type: 'image_data', image: { mediaType: 'image/png', base64: 'abc123' } };
|
|
351
|
+
yield { type: 'done' };
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
const ctx = makeCtx({ runtime });
|
|
355
|
+
const job = makeJob();
|
|
356
|
+
await executeCronJob(job, ctx);
|
|
357
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
358
|
+
const channel = guild.channels.cache.get('general');
|
|
359
|
+
expect(channel.send).toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
it('records success in statsStore when sentinel is suppressed', async () => {
|
|
362
|
+
const statsStore = {
|
|
363
|
+
recordRun: vi.fn().mockResolvedValue(undefined),
|
|
364
|
+
recordRunStart: vi.fn().mockResolvedValue(undefined),
|
|
365
|
+
getRecord: vi.fn().mockReturnValue(undefined),
|
|
366
|
+
upsertRecord: vi.fn().mockResolvedValue(undefined),
|
|
367
|
+
};
|
|
368
|
+
const ctx = makeCtx({ runtime: makeMockRuntime('HEARTBEAT_OK'), statsStore });
|
|
369
|
+
const job = makeJob();
|
|
370
|
+
await executeCronJob(job, ctx);
|
|
371
|
+
expect(statsStore.recordRun).toHaveBeenCalledWith('cron-test0001', 'success');
|
|
372
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
373
|
+
const channel = guild.channels.cache.get('general');
|
|
374
|
+
expect(channel.send).not.toHaveBeenCalled();
|
|
375
|
+
});
|
|
327
376
|
it('does not post if output is empty', async () => {
|
|
328
377
|
const ctx = makeCtx({ runtime: makeMockRuntime('') });
|
|
329
378
|
const job = makeJob();
|
|
@@ -730,3 +779,111 @@ describe('executeCronJob write-ahead status tracking', () => {
|
|
|
730
779
|
expect(rec?.lastRunStatus).toBe('success');
|
|
731
780
|
});
|
|
732
781
|
});
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
// Silent mode suppression
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
describe('executeCronJob silent mode', () => {
|
|
786
|
+
let statsDir;
|
|
787
|
+
beforeEach(async () => {
|
|
788
|
+
statsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'executor-silent-'));
|
|
789
|
+
});
|
|
790
|
+
afterEach(async () => {
|
|
791
|
+
await fs.rm(statsDir, { recursive: true, force: true });
|
|
792
|
+
});
|
|
793
|
+
function makeCapturingRuntime(response) {
|
|
794
|
+
const invokeSpy = vi.fn();
|
|
795
|
+
return {
|
|
796
|
+
runtime: {
|
|
797
|
+
id: 'claude_code',
|
|
798
|
+
capabilities: new Set(['streaming_text']),
|
|
799
|
+
async *invoke(params) {
|
|
800
|
+
invokeSpy(params);
|
|
801
|
+
yield { type: 'text_final', text: response };
|
|
802
|
+
yield { type: 'done' };
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
invokeSpy,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
it('injects HEARTBEAT_OK instruction into the prompt when silent is true', async () => {
|
|
809
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
810
|
+
const statsStore = await loadRunStats(statsPath);
|
|
811
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
|
|
812
|
+
const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
|
|
813
|
+
const ctx = makeCtx({ statsStore, runtime });
|
|
814
|
+
const job = makeJob();
|
|
815
|
+
await executeCronJob(job, ctx);
|
|
816
|
+
expect(invokeSpy).toHaveBeenCalledOnce();
|
|
817
|
+
const prompt = invokeSpy.mock.calls[0][0].prompt;
|
|
818
|
+
expect(prompt).toContain('respond with exactly `HEARTBEAT_OK`');
|
|
819
|
+
});
|
|
820
|
+
it('suppresses short responses under the threshold when silent is true', async () => {
|
|
821
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
822
|
+
const statsStore = await loadRunStats(statsPath);
|
|
823
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
|
|
824
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('No task-labeled emails found.') });
|
|
825
|
+
const job = makeJob();
|
|
826
|
+
await executeCronJob(job, ctx);
|
|
827
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
828
|
+
const channel = guild.channels.cache.get('general');
|
|
829
|
+
expect(channel.send).not.toHaveBeenCalled();
|
|
830
|
+
expect(ctx.log?.info).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, name: job.name }), 'cron:exec silent short-response suppressed');
|
|
831
|
+
});
|
|
832
|
+
it('does NOT suppress longer substantive responses when silent is true', async () => {
|
|
833
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
834
|
+
const statsStore = await loadRunStats(statsPath);
|
|
835
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
|
|
836
|
+
const longResponse = 'Here is a detailed summary of the tasks completed today, including several important updates that need your attention.';
|
|
837
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(longResponse) });
|
|
838
|
+
const job = makeJob();
|
|
839
|
+
await executeCronJob(job, ctx);
|
|
840
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
841
|
+
const channel = guild.channels.cache.get('general');
|
|
842
|
+
expect(channel.send).toHaveBeenCalledOnce();
|
|
843
|
+
expect(channel.send.mock.calls[0][0].content).toContain(longResponse);
|
|
844
|
+
});
|
|
845
|
+
it('does not apply short-response gate when silent is false', async () => {
|
|
846
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
847
|
+
const statsStore = await loadRunStats(statsPath);
|
|
848
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: false });
|
|
849
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('No emails found.') });
|
|
850
|
+
const job = makeJob();
|
|
851
|
+
await executeCronJob(job, ctx);
|
|
852
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
853
|
+
const channel = guild.channels.cache.get('general');
|
|
854
|
+
expect(channel.send).toHaveBeenCalledOnce();
|
|
855
|
+
});
|
|
856
|
+
it('does NOT suppress short text when images are present (silent mode)', async () => {
|
|
857
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
858
|
+
const statsStore = await loadRunStats(statsPath);
|
|
859
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
|
|
860
|
+
const runtime = {
|
|
861
|
+
id: 'claude_code',
|
|
862
|
+
capabilities: new Set(['streaming_text']),
|
|
863
|
+
async *invoke() {
|
|
864
|
+
yield { type: 'text_final', text: 'Short.' };
|
|
865
|
+
yield { type: 'image_data', image: { mediaType: 'image/png', base64: 'abc123' } };
|
|
866
|
+
yield { type: 'done' };
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
const ctx = makeCtx({ statsStore, runtime });
|
|
870
|
+
const job = makeJob();
|
|
871
|
+
await executeCronJob(job, ctx);
|
|
872
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
873
|
+
const channel = guild.channels.cache.get('general');
|
|
874
|
+
expect(channel.send).toHaveBeenCalled();
|
|
875
|
+
});
|
|
876
|
+
it('records success in statsStore when silent short-response is suppressed', async () => {
|
|
877
|
+
const statsPath = path.join(statsDir, 'stats.json');
|
|
878
|
+
const statsStore = await loadRunStats(statsPath);
|
|
879
|
+
await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
|
|
880
|
+
const recordRunSpy = vi.spyOn(statsStore, 'recordRun');
|
|
881
|
+
const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('Nothing to report.') });
|
|
882
|
+
const job = makeJob();
|
|
883
|
+
await executeCronJob(job, ctx);
|
|
884
|
+
expect(recordRunSpy).toHaveBeenCalledWith('cron-test0001', 'success');
|
|
885
|
+
const guild = ctx.client.guilds.cache.get('guild-1');
|
|
886
|
+
const channel = guild.channels.cache.get('general');
|
|
887
|
+
expect(channel.send).not.toHaveBeenCalled();
|
|
888
|
+
});
|
|
889
|
+
});
|
package/dist/cron/forum-sync.js
CHANGED
|
@@ -148,6 +148,12 @@ async function loadThreadAsCron(thread, guildId, scheduler, runtime, opts) {
|
|
|
148
148
|
cadence,
|
|
149
149
|
// Preserve existing disabled state.
|
|
150
150
|
disabled: existingRecord?.disabled ?? false,
|
|
151
|
+
// Persist parsed definition so future boots skip AI re-parsing.
|
|
152
|
+
schedule: def.schedule,
|
|
153
|
+
timezone: def.timezone,
|
|
154
|
+
channel: def.channel,
|
|
155
|
+
prompt: def.prompt,
|
|
156
|
+
authorId: starterAuthorId,
|
|
151
157
|
});
|
|
152
158
|
// Restore disabled state from stats.
|
|
153
159
|
if (existingRecord?.disabled) {
|
|
@@ -207,6 +213,47 @@ export async function initCronForum(opts) {
|
|
|
207
213
|
for (const thread of activeThreads.values()) {
|
|
208
214
|
if (thread.archived)
|
|
209
215
|
continue;
|
|
216
|
+
// Fast path: if the stats store already has a parsed definition for this
|
|
217
|
+
// thread, reconstruct the ParsedCronDef and register directly — skipping
|
|
218
|
+
// the AI parser entirely. This avoids wasted AI calls on every boot and
|
|
219
|
+
// prevents parse-failure errors on threads whose starters aren't parseable
|
|
220
|
+
// (e.g. bot-formatted messages created by cronCreate).
|
|
221
|
+
if (statsStore) {
|
|
222
|
+
const record = statsStore.getRecordByThreadId(thread.id);
|
|
223
|
+
if (record && record.channel && record.prompt &&
|
|
224
|
+
(record.schedule || (record.triggerType !== undefined && record.triggerType !== 'schedule'))) {
|
|
225
|
+
// Authorization: verify stored author is still permitted.
|
|
226
|
+
const authorId = record.authorId ?? '';
|
|
227
|
+
const botUserId = client.user?.id ?? '';
|
|
228
|
+
const isBotAuthored = botUserId !== '' && authorId === botUserId;
|
|
229
|
+
if (!authorId || (!allowUserIds.has(authorId) && !isBotAuthored)) {
|
|
230
|
+
// Author not authorized — fall through to loadThreadAsCron which
|
|
231
|
+
// will reject and disable through its existing path.
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const def = {
|
|
235
|
+
triggerType: record.triggerType ?? 'schedule',
|
|
236
|
+
schedule: record.schedule,
|
|
237
|
+
timezone: record.timezone ?? 'UTC',
|
|
238
|
+
channel: record.channel,
|
|
239
|
+
prompt: record.prompt,
|
|
240
|
+
};
|
|
241
|
+
try {
|
|
242
|
+
scheduler.register(thread.id, thread.id, guildId, thread.name, def, record.cronId);
|
|
243
|
+
if (record.disabled) {
|
|
244
|
+
scheduler.disable(thread.id);
|
|
245
|
+
log?.info({ threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path restored disabled state');
|
|
246
|
+
}
|
|
247
|
+
loaded++;
|
|
248
|
+
log?.info({ threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path loaded from stored definition');
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
log?.warn({ err, threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path register failed, falling through to AI parse');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
210
257
|
const ok = await loadThreadAsCron(thread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew: false, allowUserIds, statsStore });
|
|
211
258
|
if (ok)
|
|
212
259
|
loaded++;
|
|
@@ -310,6 +310,240 @@ describe('initCronForum', () => {
|
|
|
310
310
|
});
|
|
311
311
|
expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({ schedule: '0 7 * * *' }), 'cron-from-status-id');
|
|
312
312
|
});
|
|
313
|
+
it('skips AI parse when stats store has stored definition (fast path)', async () => {
|
|
314
|
+
const thread = makeThread();
|
|
315
|
+
// fetchStarterMessage should NOT be called — fast path bypasses loadThreadAsCron entirely.
|
|
316
|
+
thread.fetchStarterMessage.mockResolvedValue(null);
|
|
317
|
+
const forum = makeForum([thread]);
|
|
318
|
+
const client = makeClient(forum);
|
|
319
|
+
const scheduler = makeScheduler();
|
|
320
|
+
scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
|
|
321
|
+
const statsStore = {
|
|
322
|
+
getRecordByThreadId: vi.fn().mockReturnValue({
|
|
323
|
+
cronId: 'cron-stored',
|
|
324
|
+
threadId: 'thread-1',
|
|
325
|
+
disabled: false,
|
|
326
|
+
schedule: '0 7 * * *',
|
|
327
|
+
timezone: 'America/New_York',
|
|
328
|
+
channel: 'general',
|
|
329
|
+
prompt: 'Say good morning.',
|
|
330
|
+
authorId: 'u-allowed',
|
|
331
|
+
triggerType: 'schedule',
|
|
332
|
+
}),
|
|
333
|
+
getRecord: vi.fn(),
|
|
334
|
+
upsertRecord: vi.fn(async () => ({})),
|
|
335
|
+
};
|
|
336
|
+
await initCronForum({
|
|
337
|
+
client: client,
|
|
338
|
+
forumChannelNameOrId: 'forum-1',
|
|
339
|
+
allowUserIds: new Set(['u-allowed']),
|
|
340
|
+
scheduler: scheduler,
|
|
341
|
+
runtime: {},
|
|
342
|
+
cronModel: 'haiku',
|
|
343
|
+
cwd: '/tmp',
|
|
344
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
345
|
+
statsStore: statsStore,
|
|
346
|
+
});
|
|
347
|
+
// AI parser should never be called.
|
|
348
|
+
expect(parseCronDefinition).not.toHaveBeenCalled();
|
|
349
|
+
// scheduler.register should receive the stored values.
|
|
350
|
+
expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({
|
|
351
|
+
triggerType: 'schedule',
|
|
352
|
+
schedule: '0 7 * * *',
|
|
353
|
+
timezone: 'America/New_York',
|
|
354
|
+
channel: 'general',
|
|
355
|
+
prompt: 'Say good morning.',
|
|
356
|
+
}), 'cron-stored');
|
|
357
|
+
// fetchStarterMessage should not have been called (fast path skips loadThreadAsCron).
|
|
358
|
+
expect(thread.fetchStarterMessage).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
it('falls through to AI parse when stats store record lacks definition fields', async () => {
|
|
361
|
+
const thread = makeThread();
|
|
362
|
+
thread.fetchStarterMessage.mockResolvedValue({
|
|
363
|
+
id: 'm1',
|
|
364
|
+
content: 'every day at 7am post to #general say hello',
|
|
365
|
+
author: { id: 'u-allowed' },
|
|
366
|
+
react: vi.fn().mockResolvedValue(undefined),
|
|
367
|
+
});
|
|
368
|
+
thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
|
|
369
|
+
const forum = makeForum([thread]);
|
|
370
|
+
const client = makeClient(forum);
|
|
371
|
+
const scheduler = makeScheduler();
|
|
372
|
+
scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
|
|
373
|
+
vi.mocked(parseCronDefinition).mockResolvedValue({
|
|
374
|
+
triggerType: 'schedule',
|
|
375
|
+
schedule: '0 7 * * *',
|
|
376
|
+
timezone: 'UTC',
|
|
377
|
+
channel: 'general',
|
|
378
|
+
prompt: 'Say hello.',
|
|
379
|
+
});
|
|
380
|
+
// Record exists but has no stored definition fields (pre-upgrade record).
|
|
381
|
+
const statsStore = {
|
|
382
|
+
getRecordByThreadId: vi.fn().mockReturnValue({
|
|
383
|
+
cronId: 'cron-old',
|
|
384
|
+
threadId: 'thread-1',
|
|
385
|
+
disabled: false,
|
|
386
|
+
}),
|
|
387
|
+
getRecord: vi.fn().mockReturnValue({
|
|
388
|
+
cronId: 'cron-old',
|
|
389
|
+
threadId: 'thread-1',
|
|
390
|
+
disabled: false,
|
|
391
|
+
}),
|
|
392
|
+
upsertRecord: vi.fn(async () => ({})),
|
|
393
|
+
};
|
|
394
|
+
await initCronForum({
|
|
395
|
+
client: client,
|
|
396
|
+
forumChannelNameOrId: 'forum-1',
|
|
397
|
+
allowUserIds: new Set(['u-allowed']),
|
|
398
|
+
scheduler: scheduler,
|
|
399
|
+
runtime: {},
|
|
400
|
+
cronModel: 'haiku',
|
|
401
|
+
cwd: '/tmp',
|
|
402
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
403
|
+
statsStore: statsStore,
|
|
404
|
+
});
|
|
405
|
+
// Should fall through to AI parse since record lacks definition fields.
|
|
406
|
+
expect(parseCronDefinition).toHaveBeenCalled();
|
|
407
|
+
});
|
|
408
|
+
it('falls through to AI parse when fast-path register throws', async () => {
|
|
409
|
+
const thread = makeThread();
|
|
410
|
+
thread.fetchStarterMessage.mockResolvedValue({
|
|
411
|
+
id: 'm1',
|
|
412
|
+
content: 'every day at 7am post to #general say hello',
|
|
413
|
+
author: { id: 'u-allowed' },
|
|
414
|
+
react: vi.fn().mockResolvedValue(undefined),
|
|
415
|
+
});
|
|
416
|
+
thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
|
|
417
|
+
const forum = makeForum([thread]);
|
|
418
|
+
const client = makeClient(forum);
|
|
419
|
+
const scheduler = makeScheduler();
|
|
420
|
+
// First call (fast path) throws; second call (loadThreadAsCron fallback) succeeds.
|
|
421
|
+
scheduler.register
|
|
422
|
+
.mockImplementationOnce(() => { throw new Error('corrupt schedule'); })
|
|
423
|
+
.mockReturnValueOnce({ cron: { nextRun: () => new Date() } });
|
|
424
|
+
vi.mocked(parseCronDefinition).mockResolvedValue({
|
|
425
|
+
triggerType: 'schedule',
|
|
426
|
+
schedule: '0 7 * * *',
|
|
427
|
+
timezone: 'UTC',
|
|
428
|
+
channel: 'general',
|
|
429
|
+
prompt: 'Say hello.',
|
|
430
|
+
});
|
|
431
|
+
const statsStore = {
|
|
432
|
+
getRecordByThreadId: vi.fn().mockReturnValue({
|
|
433
|
+
cronId: 'cron-corrupt',
|
|
434
|
+
threadId: 'thread-1',
|
|
435
|
+
disabled: false,
|
|
436
|
+
schedule: 'BAD SCHEDULE',
|
|
437
|
+
timezone: 'UTC',
|
|
438
|
+
channel: 'general',
|
|
439
|
+
prompt: 'Say hello.',
|
|
440
|
+
authorId: 'u-allowed',
|
|
441
|
+
}),
|
|
442
|
+
getRecord: vi.fn().mockReturnValue({
|
|
443
|
+
cronId: 'cron-corrupt',
|
|
444
|
+
threadId: 'thread-1',
|
|
445
|
+
disabled: false,
|
|
446
|
+
}),
|
|
447
|
+
upsertRecord: vi.fn(async () => ({})),
|
|
448
|
+
};
|
|
449
|
+
await initCronForum({
|
|
450
|
+
client: client,
|
|
451
|
+
forumChannelNameOrId: 'forum-1',
|
|
452
|
+
allowUserIds: new Set(['u-allowed']),
|
|
453
|
+
scheduler: scheduler,
|
|
454
|
+
runtime: {},
|
|
455
|
+
cronModel: 'haiku',
|
|
456
|
+
cwd: '/tmp',
|
|
457
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
458
|
+
statsStore: statsStore,
|
|
459
|
+
});
|
|
460
|
+
// Fast path failed, should fall through to AI parse.
|
|
461
|
+
expect(parseCronDefinition).toHaveBeenCalled();
|
|
462
|
+
// scheduler.register should have been called twice (fast path + fallback).
|
|
463
|
+
expect(scheduler.register).toHaveBeenCalledTimes(2);
|
|
464
|
+
});
|
|
465
|
+
it('disabled state restored via fast path', async () => {
|
|
466
|
+
const thread = makeThread();
|
|
467
|
+
thread.fetchStarterMessage.mockResolvedValue(null);
|
|
468
|
+
const forum = makeForum([thread]);
|
|
469
|
+
const client = makeClient(forum);
|
|
470
|
+
const scheduler = makeScheduler();
|
|
471
|
+
scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
|
|
472
|
+
const statsStore = {
|
|
473
|
+
getRecordByThreadId: vi.fn().mockReturnValue({
|
|
474
|
+
cronId: 'cron-disabled-fp',
|
|
475
|
+
threadId: 'thread-1',
|
|
476
|
+
disabled: true,
|
|
477
|
+
schedule: '0 7 * * *',
|
|
478
|
+
timezone: 'UTC',
|
|
479
|
+
channel: 'general',
|
|
480
|
+
prompt: 'Say hello.',
|
|
481
|
+
authorId: 'u-allowed',
|
|
482
|
+
}),
|
|
483
|
+
getRecord: vi.fn(),
|
|
484
|
+
upsertRecord: vi.fn(async () => ({})),
|
|
485
|
+
};
|
|
486
|
+
await initCronForum({
|
|
487
|
+
client: client,
|
|
488
|
+
forumChannelNameOrId: 'forum-1',
|
|
489
|
+
allowUserIds: new Set(['u-allowed']),
|
|
490
|
+
scheduler: scheduler,
|
|
491
|
+
runtime: {},
|
|
492
|
+
cronModel: 'haiku',
|
|
493
|
+
cwd: '/tmp',
|
|
494
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
495
|
+
statsStore: statsStore,
|
|
496
|
+
});
|
|
497
|
+
// Should register then immediately disable.
|
|
498
|
+
expect(scheduler.register).toHaveBeenCalledOnce();
|
|
499
|
+
expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
|
|
500
|
+
// AI parser should NOT be called.
|
|
501
|
+
expect(parseCronDefinition).not.toHaveBeenCalled();
|
|
502
|
+
});
|
|
503
|
+
it('falls through when stored authorId is not in allowlist', async () => {
|
|
504
|
+
const thread = makeThread();
|
|
505
|
+
thread.fetchStarterMessage.mockResolvedValue({
|
|
506
|
+
id: 'm1',
|
|
507
|
+
content: 'every day at 7am post to #general say hello',
|
|
508
|
+
author: { id: 'u-not-allowed' },
|
|
509
|
+
react: vi.fn().mockResolvedValue(undefined),
|
|
510
|
+
});
|
|
511
|
+
thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
|
|
512
|
+
const forum = makeForum([thread]);
|
|
513
|
+
const client = makeClient(forum);
|
|
514
|
+
const scheduler = makeScheduler();
|
|
515
|
+
const statsStore = {
|
|
516
|
+
getRecordByThreadId: vi.fn().mockReturnValue({
|
|
517
|
+
cronId: 'cron-unauth',
|
|
518
|
+
threadId: 'thread-1',
|
|
519
|
+
disabled: false,
|
|
520
|
+
schedule: '0 7 * * *',
|
|
521
|
+
timezone: 'UTC',
|
|
522
|
+
channel: 'general',
|
|
523
|
+
prompt: 'Say hello.',
|
|
524
|
+
authorId: 'u-not-allowed',
|
|
525
|
+
}),
|
|
526
|
+
getRecord: vi.fn(),
|
|
527
|
+
upsertRecord: vi.fn(async () => ({})),
|
|
528
|
+
};
|
|
529
|
+
await initCronForum({
|
|
530
|
+
client: client,
|
|
531
|
+
forumChannelNameOrId: 'forum-1',
|
|
532
|
+
allowUserIds: new Set(['u-allowed']),
|
|
533
|
+
scheduler: scheduler,
|
|
534
|
+
runtime: {},
|
|
535
|
+
cronModel: 'haiku',
|
|
536
|
+
cwd: '/tmp',
|
|
537
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
538
|
+
statsStore: statsStore,
|
|
539
|
+
});
|
|
540
|
+
// Should fall through to loadThreadAsCron which will reject and disable.
|
|
541
|
+
// fetchStarterMessage is called by loadThreadAsCron.
|
|
542
|
+
expect(thread.fetchStarterMessage).toHaveBeenCalled();
|
|
543
|
+
// scheduler.register should NOT be called (auth fails in loadThreadAsCron too).
|
|
544
|
+
expect(scheduler.register).not.toHaveBeenCalled();
|
|
545
|
+
expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
|
|
546
|
+
});
|
|
313
547
|
it('restores disabled state from stats store', async () => {
|
|
314
548
|
const thread = makeThread();
|
|
315
549
|
thread.fetchStarterMessage.mockResolvedValue({
|
package/dist/cron/run-stats.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
// Types
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
export const CADENCE_TAGS = ['yearly', 'frequent', 'hourly', 'daily', 'weekly', 'monthly'];
|
|
8
|
-
export const CURRENT_VERSION =
|
|
8
|
+
export const CURRENT_VERSION = 6;
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Stable Cron ID generation
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -292,7 +292,14 @@ export async function loadRunStats(filePath) {
|
|
|
292
292
|
if (store.version === 3) {
|
|
293
293
|
store.version = 4;
|
|
294
294
|
}
|
|
295
|
-
//
|
|
296
|
-
|
|
295
|
+
// Migrate v4 → v5: no-op — new field (silent) is optional and defaults falsy.
|
|
296
|
+
if (store.version === 4) {
|
|
297
|
+
store.version = 5;
|
|
298
|
+
}
|
|
299
|
+
// Migrate v5 → v6: no-op — new persisted definition fields (schedule, timezone, channel, prompt, authorId) are optional.
|
|
300
|
+
// Absent records fall through to AI parsing on first boot after upgrade.
|
|
301
|
+
if (store.version === 5) {
|
|
302
|
+
store.version = 6;
|
|
303
|
+
}
|
|
297
304
|
return new CronRunStats(store, filePath);
|
|
298
305
|
}
|
|
@@ -38,7 +38,7 @@ describe('CronRunStats', () => {
|
|
|
38
38
|
it('creates empty store on missing file', async () => {
|
|
39
39
|
const stats = await loadRunStats(statsPath);
|
|
40
40
|
const store = stats.getStore();
|
|
41
|
-
expect(store.version).toBe(
|
|
41
|
+
expect(store.version).toBe(6);
|
|
42
42
|
expect(Object.keys(store.jobs)).toHaveLength(0);
|
|
43
43
|
});
|
|
44
44
|
it('upserts and retrieves records by cronId', async () => {
|
|
@@ -210,7 +210,7 @@ describe('CronRunStats', () => {
|
|
|
210
210
|
describe('emptyStore', () => {
|
|
211
211
|
it('returns valid initial structure', () => {
|
|
212
212
|
const store = emptyStore();
|
|
213
|
-
expect(store.version).toBe(
|
|
213
|
+
expect(store.version).toBe(6);
|
|
214
214
|
expect(store.updatedAt).toBeGreaterThan(0);
|
|
215
215
|
expect(Object.keys(store.jobs)).toHaveLength(0);
|
|
216
216
|
});
|
|
@@ -237,7 +237,7 @@ describe('loadRunStats version migration', () => {
|
|
|
237
237
|
};
|
|
238
238
|
await fs.writeFile(statsPath, JSON.stringify(v3Store), 'utf-8');
|
|
239
239
|
const stats = await loadRunStats(statsPath);
|
|
240
|
-
expect(stats.getStore().version).toBe(
|
|
240
|
+
expect(stats.getStore().version).toBe(6);
|
|
241
241
|
const rec = stats.getRecord('cron-migrated');
|
|
242
242
|
expect(rec).toBeDefined();
|
|
243
243
|
expect(rec.cronId).toBe('cron-migrated');
|
|
@@ -246,4 +246,68 @@ describe('loadRunStats version migration', () => {
|
|
|
246
246
|
expect(rec.cadence).toBe('daily');
|
|
247
247
|
expect(rec.purposeTags).toEqual(['monitoring']);
|
|
248
248
|
});
|
|
249
|
+
it('migrates a v4 store to v5 with silent undefined on existing records', async () => {
|
|
250
|
+
const v4Store = {
|
|
251
|
+
version: 4,
|
|
252
|
+
updatedAt: Date.now(),
|
|
253
|
+
jobs: {
|
|
254
|
+
'cron-v4': {
|
|
255
|
+
cronId: 'cron-v4',
|
|
256
|
+
threadId: 'thread-v4',
|
|
257
|
+
runCount: 3,
|
|
258
|
+
lastRunAt: '2025-06-01T00:00:00.000Z',
|
|
259
|
+
lastRunStatus: 'success',
|
|
260
|
+
cadence: 'hourly',
|
|
261
|
+
purposeTags: ['email'],
|
|
262
|
+
disabled: false,
|
|
263
|
+
model: 'sonnet',
|
|
264
|
+
triggerType: 'schedule',
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
await fs.writeFile(statsPath, JSON.stringify(v4Store), 'utf-8');
|
|
269
|
+
const stats = await loadRunStats(statsPath);
|
|
270
|
+
expect(stats.getStore().version).toBe(6);
|
|
271
|
+
const rec = stats.getRecord('cron-v4');
|
|
272
|
+
expect(rec).toBeDefined();
|
|
273
|
+
expect(rec.cronId).toBe('cron-v4');
|
|
274
|
+
expect(rec.runCount).toBe(3);
|
|
275
|
+
expect(rec.cadence).toBe('hourly');
|
|
276
|
+
expect(rec.silent).toBeUndefined();
|
|
277
|
+
});
|
|
278
|
+
it('migrates a v5 store to v6 with definition fields undefined on existing records', async () => {
|
|
279
|
+
const v5Store = {
|
|
280
|
+
version: 5,
|
|
281
|
+
updatedAt: Date.now(),
|
|
282
|
+
jobs: {
|
|
283
|
+
'cron-v5': {
|
|
284
|
+
cronId: 'cron-v5',
|
|
285
|
+
threadId: 'thread-v5',
|
|
286
|
+
runCount: 7,
|
|
287
|
+
lastRunAt: '2025-08-01T00:00:00.000Z',
|
|
288
|
+
lastRunStatus: 'success',
|
|
289
|
+
cadence: 'daily',
|
|
290
|
+
purposeTags: ['greeting'],
|
|
291
|
+
disabled: false,
|
|
292
|
+
model: 'haiku',
|
|
293
|
+
triggerType: 'schedule',
|
|
294
|
+
silent: true,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
await fs.writeFile(statsPath, JSON.stringify(v5Store), 'utf-8');
|
|
299
|
+
const stats = await loadRunStats(statsPath);
|
|
300
|
+
expect(stats.getStore().version).toBe(6);
|
|
301
|
+
const rec = stats.getRecord('cron-v5');
|
|
302
|
+
expect(rec).toBeDefined();
|
|
303
|
+
expect(rec.cronId).toBe('cron-v5');
|
|
304
|
+
expect(rec.runCount).toBe(7);
|
|
305
|
+
expect(rec.cadence).toBe('daily');
|
|
306
|
+
expect(rec.silent).toBe(true);
|
|
307
|
+
expect(rec.schedule).toBeUndefined();
|
|
308
|
+
expect(rec.timezone).toBeUndefined();
|
|
309
|
+
expect(rec.channel).toBeUndefined();
|
|
310
|
+
expect(rec.prompt).toBeUndefined();
|
|
311
|
+
expect(rec.authorId).toBeUndefined();
|
|
312
|
+
});
|
|
249
313
|
});
|
|
@@ -147,6 +147,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
147
147
|
cadence,
|
|
148
148
|
purposeTags,
|
|
149
149
|
model,
|
|
150
|
+
schedule: action.schedule,
|
|
151
|
+
timezone,
|
|
152
|
+
channel: action.channel,
|
|
153
|
+
prompt: action.prompt,
|
|
154
|
+
authorId: cronCtx.client.user?.id,
|
|
150
155
|
});
|
|
151
156
|
// Create status message.
|
|
152
157
|
try {
|
|
@@ -214,7 +219,8 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
214
219
|
}
|
|
215
220
|
else {
|
|
216
221
|
// Can't edit user's message — post update note.
|
|
217
|
-
const
|
|
222
|
+
const promptPreview = newPrompt.length > 200 ? `${newPrompt.slice(0, 200)}... (truncated)` : newPrompt;
|
|
223
|
+
const note = `**Cron Updated**\n**Schedule:** \`${newSchedule}\` (${newTimezone})\n**Channel:** #${newChannel}\n**Prompt:** ${promptPreview}\n\nPlease update the starter message to reflect these changes.`;
|
|
218
224
|
await thread.send({ content: note, allowedMentions: { parse: [] } });
|
|
219
225
|
}
|
|
220
226
|
}
|
|
@@ -230,6 +236,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
230
236
|
const msg = err instanceof Error ? err.message : String(err);
|
|
231
237
|
return { ok: false, error: `Invalid cron definition: ${msg}` };
|
|
232
238
|
}
|
|
239
|
+
// Persist updated definition fields.
|
|
240
|
+
updates.schedule = newSchedule;
|
|
241
|
+
updates.timezone = newTimezone;
|
|
242
|
+
updates.channel = newChannel;
|
|
243
|
+
updates.prompt = newPrompt;
|
|
233
244
|
}
|
|
234
245
|
await cronCtx.statsStore.upsertRecord(action.cronId, record.threadId, updates);
|
|
235
246
|
// Update status message.
|
|
@@ -320,6 +331,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
320
331
|
lines.push(`Tags: ${record.purposeTags.join(', ')}`);
|
|
321
332
|
if (record.lastErrorMessage)
|
|
322
333
|
lines.push(`Last error: ${record.lastErrorMessage}`);
|
|
334
|
+
if (job) {
|
|
335
|
+
const promptText = job.def.prompt;
|
|
336
|
+
const truncated = promptText.length > 500 ? `${promptText.slice(0, 500)}... (truncated)` : promptText;
|
|
337
|
+
lines.push(`Prompt: ${truncated}`);
|
|
338
|
+
}
|
|
323
339
|
return { ok: true, summary: lines.join('\n') };
|
|
324
340
|
}
|
|
325
341
|
case 'cronPause': {
|
|
@@ -484,6 +484,48 @@ describe('executeCronAction', () => {
|
|
|
484
484
|
expect(result.summary).toContain('no sync coordinator configured');
|
|
485
485
|
}
|
|
486
486
|
});
|
|
487
|
+
it('cronShow includes Prompt line with job prompt text', async () => {
|
|
488
|
+
const cronCtx = makeCronCtx();
|
|
489
|
+
const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
490
|
+
expect(result.ok).toBe(true);
|
|
491
|
+
if (result.ok) {
|
|
492
|
+
expect(result.summary).toContain('Prompt: Test');
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
it('cronShow truncates prompt longer than 500 chars', async () => {
|
|
496
|
+
const cronCtx = makeCronCtx();
|
|
497
|
+
const job = cronCtx.scheduler.getJob('thread-1');
|
|
498
|
+
job.def.prompt = 'x'.repeat(600);
|
|
499
|
+
const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
500
|
+
expect(result.ok).toBe(true);
|
|
501
|
+
if (result.ok) {
|
|
502
|
+
expect(result.summary).toContain('... (truncated)');
|
|
503
|
+
expect(result.summary).not.toContain('x'.repeat(600));
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
it('cronShow omits Prompt line when scheduler job is missing', async () => {
|
|
507
|
+
const cronCtx = makeCronCtx({ scheduler: makeScheduler([]) });
|
|
508
|
+
const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
509
|
+
expect(result.ok).toBe(true);
|
|
510
|
+
if (result.ok) {
|
|
511
|
+
expect(result.summary).not.toContain('Prompt:');
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
it('cronUpdate fallback note includes prompt when starter is not bot-owned', async () => {
|
|
515
|
+
const cronCtx = makeCronCtx();
|
|
516
|
+
const threadSend = vi.fn(async () => ({}));
|
|
517
|
+
const mockThread = {
|
|
518
|
+
id: 'thread-1',
|
|
519
|
+
isThread: () => true,
|
|
520
|
+
send: threadSend,
|
|
521
|
+
fetchStarterMessage: vi.fn(async () => ({ author: { id: 'other-user' }, edit: vi.fn() })),
|
|
522
|
+
setArchived: vi.fn(),
|
|
523
|
+
};
|
|
524
|
+
cronCtx.client.channels.cache.get.mockImplementation((id) => id === 'thread-1' ? mockThread : undefined);
|
|
525
|
+
const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', prompt: 'New prompt text' }, makeActionCtx(), cronCtx);
|
|
526
|
+
expect(result.ok).toBe(true);
|
|
527
|
+
expect(threadSend).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('New prompt text') }));
|
|
528
|
+
});
|
|
487
529
|
it('cronTagMapReload failure returns error', async () => {
|
|
488
530
|
const { reloadCronTagMapInPlace } = await import('../cron/tag-map.js');
|
|
489
531
|
vi.mocked(reloadCronTagMapInPlace).mockRejectedValue(new Error('bad json'));
|
|
@@ -43,6 +43,9 @@ export function mapRuntimeErrorToUserMessage(raw) {
|
|
|
43
43
|
return ('This channel is missing required context. Create/index the channel context file under content/discord/channels ' +
|
|
44
44
|
'or disable DISCORD_REQUIRE_CHANNEL_CONTEXT.');
|
|
45
45
|
}
|
|
46
|
+
if (lc.includes('prompt is too long') || lc.includes('context length exceeded') || lc.includes('context_length_exceeded')) {
|
|
47
|
+
return 'The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.';
|
|
48
|
+
}
|
|
46
49
|
if (!msg) {
|
|
47
50
|
return 'An unexpected runtime error occurred with no additional detail.';
|
|
48
51
|
}
|
|
@@ -36,4 +36,12 @@ describe('mapRuntimeErrorToUserMessage', () => {
|
|
|
36
36
|
expect(msg).toContain('DISCOCLAW_STREAM_STALL_TIMEOUT_MS');
|
|
37
37
|
expect(msg).not.toContain('ms /');
|
|
38
38
|
});
|
|
39
|
+
it('maps "Prompt is too long" to context overflow user message', () => {
|
|
40
|
+
const msg = mapRuntimeErrorToUserMessage('Prompt is too long');
|
|
41
|
+
expect(msg).toBe('The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.');
|
|
42
|
+
});
|
|
43
|
+
it('maps "context_length_exceeded" to context overflow user message', () => {
|
|
44
|
+
const msg = mapRuntimeErrorToUserMessage('context_length_exceeded');
|
|
45
|
+
expect(msg).toBe('The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.');
|
|
46
|
+
});
|
|
39
47
|
});
|
|
@@ -10,6 +10,16 @@ import { STDIN_THRESHOLD, tryParseJsonLine, createEventQueue, SubprocessTracker,
|
|
|
10
10
|
import { extractTextFromUnknownEvent, extractResultText, extractImageFromUnknownEvent, extractResultContentBlocks, imageDedupeKey, stripToolUseBlocks, } from './cli-output-parsers.js';
|
|
11
11
|
// Global subprocess tracker shared across all CLI adapters.
|
|
12
12
|
const globalTracker = new SubprocessTracker();
|
|
13
|
+
const CONTEXT_OVERFLOW_PHRASES = [
|
|
14
|
+
'prompt is too long',
|
|
15
|
+
'context length exceeded',
|
|
16
|
+
'context_length_exceeded',
|
|
17
|
+
'context overflow',
|
|
18
|
+
];
|
|
19
|
+
function isContextOverflowMessage(text) {
|
|
20
|
+
const lower = text.toLowerCase();
|
|
21
|
+
return CONTEXT_OVERFLOW_PHRASES.some((phrase) => lower.includes(phrase));
|
|
22
|
+
}
|
|
13
23
|
function asCliLogLike(log) {
|
|
14
24
|
if (!log || typeof log !== 'object')
|
|
15
25
|
return undefined;
|
|
@@ -95,10 +105,19 @@ export function createCliRuntime(strategy, opts) {
|
|
|
95
105
|
const onPoolAbort = () => { proc.kill?.(); };
|
|
96
106
|
params.signal?.addEventListener('abort', onPoolAbort, { once: true });
|
|
97
107
|
let fallback = false;
|
|
108
|
+
let contextOverflow = false;
|
|
98
109
|
try {
|
|
99
110
|
for await (const evt of proc.sendTurn(params.prompt, params.images)) {
|
|
100
111
|
if (evt.type === 'error' && (evt.message.startsWith('long-running:') || evt.message.includes('hang detected'))) {
|
|
101
|
-
|
|
112
|
+
if (evt.message.includes('context overflow'))
|
|
113
|
+
contextOverflow = true;
|
|
114
|
+
pool.remove(params.sessionKey, contextOverflow ? 'context-overflow' : undefined);
|
|
115
|
+
fallback = true;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if ((evt.type === 'text_delta' || evt.type === 'text_final') && isContextOverflowMessage(evt.text)) {
|
|
119
|
+
pool.remove(params.sessionKey, 'context-overflow');
|
|
120
|
+
contextOverflow = true;
|
|
102
121
|
fallback = true;
|
|
103
122
|
break;
|
|
104
123
|
}
|
|
@@ -112,6 +131,10 @@ export function createCliRuntime(strategy, opts) {
|
|
|
112
131
|
globalTracker.delete(sub);
|
|
113
132
|
if (!fallback)
|
|
114
133
|
return;
|
|
134
|
+
if (contextOverflow) {
|
|
135
|
+
cliLog?.info?.({ sessionKey: params.sessionKey }, 'multi-turn: context overflow, resetting session and retrying');
|
|
136
|
+
yield { type: 'text_delta', text: '*(Session reset — conversation context limit reached. Starting fresh.)*\n\n' };
|
|
137
|
+
}
|
|
115
138
|
cliLog?.info?.('multi-turn: process failed, falling back to one-shot');
|
|
116
139
|
}
|
|
117
140
|
}
|
|
@@ -288,11 +288,23 @@ export class LongRunningProcess {
|
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
+
isContextOverflow(text) {
|
|
292
|
+
const lower = text.toLowerCase();
|
|
293
|
+
return (lower.includes('prompt is too long') ||
|
|
294
|
+
lower.includes('context length exceeded') ||
|
|
295
|
+
lower.includes('context_length_exceeded'));
|
|
296
|
+
}
|
|
291
297
|
finalizeTurn() {
|
|
292
298
|
const raw = this.turnResultText.trim() || (this.turnMerged.trim() ? this.turnMerged.trimEnd() : '');
|
|
293
299
|
const final = stripToolUseBlocks(raw);
|
|
294
|
-
if (final)
|
|
295
|
-
this.
|
|
300
|
+
if (final) {
|
|
301
|
+
if (this.isContextOverflow(final)) {
|
|
302
|
+
this.pushEvent({ type: 'error', message: 'long-running: context overflow' });
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
this.pushEvent({ type: 'text_final', text: final });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
296
308
|
this.pushDoneOnce();
|
|
297
309
|
}
|
|
298
310
|
handleExit() {
|
|
@@ -299,6 +299,23 @@ describe('LongRunningProcess', () => {
|
|
|
299
299
|
expect(callArgs).not.toContain('--max-budget-usd');
|
|
300
300
|
expect(callArgs).not.toContain('--append-system-prompt');
|
|
301
301
|
});
|
|
302
|
+
it('context overflow result emits error event instead of text_final', async () => {
|
|
303
|
+
const mock = createMockSubprocess();
|
|
304
|
+
execa.mockReturnValue(mock.proc);
|
|
305
|
+
const proc = new LongRunningProcess(baseOpts);
|
|
306
|
+
proc.spawn();
|
|
307
|
+
queueMicrotask(() => {
|
|
308
|
+
mock.stdout.emit('data', JSON.stringify({ type: 'result', result: 'Prompt is too long' }) + '\n');
|
|
309
|
+
});
|
|
310
|
+
const events = [];
|
|
311
|
+
for await (const evt of proc.sendTurn('tell me everything')) {
|
|
312
|
+
events.push(evt);
|
|
313
|
+
}
|
|
314
|
+
expect(events.find((e) => e.type === 'error')?.message).toBe('long-running: context overflow');
|
|
315
|
+
expect(events.find((e) => e.type === 'done')).toBeTruthy();
|
|
316
|
+
expect(events.find((e) => e.type === 'text_final')).toBeUndefined();
|
|
317
|
+
expect(proc.state).toBe('idle');
|
|
318
|
+
});
|
|
302
319
|
it('sendTurn without images writes plain string content (no regression)', async () => {
|
|
303
320
|
const mock = createMockSubprocess();
|
|
304
321
|
execa.mockReturnValue(mock.proc);
|
|
@@ -59,12 +59,12 @@ export class ProcessPool {
|
|
|
59
59
|
return proc;
|
|
60
60
|
}
|
|
61
61
|
/** Kill and remove a specific session's process. */
|
|
62
|
-
remove(sessionKey) {
|
|
62
|
+
remove(sessionKey, reason) {
|
|
63
63
|
const proc = this.pool.get(sessionKey);
|
|
64
64
|
if (proc) {
|
|
65
65
|
this.pool.delete(sessionKey);
|
|
66
66
|
proc.kill();
|
|
67
|
-
this.log?.info({ sessionKey }, 'process-pool: removed process');
|
|
67
|
+
this.log?.info({ sessionKey, reason }, 'process-pool: removed process');
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
/** Kill all processes (shutdown cleanup). */
|
|
@@ -16,7 +16,6 @@ const ALL_TEMPLATE_FILES = [
|
|
|
16
16
|
'USER.md',
|
|
17
17
|
'AGENTS.md',
|
|
18
18
|
'TOOLS.md',
|
|
19
|
-
'HEARTBEAT.md',
|
|
20
19
|
'MEMORY.md',
|
|
21
20
|
];
|
|
22
21
|
/** Real IDENTITY.md content that passes onboarding check (no template marker). */
|
|
@@ -199,7 +198,6 @@ describe('ensureWorkspaceBootstrapFiles', () => {
|
|
|
199
198
|
'SOUL.md': 'My soul',
|
|
200
199
|
'TOOLS.md': 'My tools',
|
|
201
200
|
'USER.md': 'My user',
|
|
202
|
-
'HEARTBEAT.md': 'My heartbeat',
|
|
203
201
|
'MEMORY.md': 'My memory',
|
|
204
202
|
};
|
|
205
203
|
for (const [file, content] of Object.entries(customFiles)) {
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ Wants=network-online.target
|
|
|
5
5
|
|
|
6
6
|
[Service]
|
|
7
7
|
Type=simple
|
|
8
|
+
Environment=PATH=%h/.local/bin:%h/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
|
|
8
9
|
WorkingDirectory=%h/code/discoclaw
|
|
9
10
|
# Keep secrets local; this file should exist on the host (not committed).
|
|
10
11
|
EnvironmentFile=%h/code/discoclaw/.env
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# HEARTBEAT.md
|
|
2
|
-
|
|
3
|
-
# Keep this file empty (or with only comments) to skip heartbeat tasks.
|
|
4
|
-
|
|
5
|
-
# Add tasks below when you want the agent to check something periodically.
|
|
6
|
-
|
|
7
|
-
# --- Memory maintenance (uncomment to enable) ---
|
|
8
|
-
# - Review daily logs older than 7 days in memory/. Distill anything worth keeping into MEMORY.md, then delete the old daily file.
|
|
9
|
-
# - Check MEMORY.md for stale or superseded entries. Remove anything no longer relevant.
|
|
10
|
-
# - If MEMORY.md exceeds ~2 KB, prune aggressively — keep only what matters for future sessions.
|