kimaki 0.25.0 → 0.26.0
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/dist/agent-model.e2e.test.js +135 -3
- package/dist/analytics.test.js +30 -1
- package/dist/cli-commands/send.js +13 -3
- package/dist/cli-commands/task.js +26 -7
- package/dist/cli-runner.js +66 -70
- package/dist/cli-runner.test.js +10 -1
- package/dist/commands/agent.js +87 -74
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +7 -1
- package/dist/ipc-tools-plugin.js +4 -3
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/message-formatting.js +84 -5
- package/dist/message-formatting.test.js +160 -2
- package/dist/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -0
- package/dist/schema.js +19 -0
- package/dist/session-handler/event-stream-state.js +147 -0
- package/dist/session-handler/event-stream-state.test.js +437 -1
- package/dist/session-handler/thread-session-runtime.js +90 -37
- package/dist/subagent-rate-limit-plugin.js +13 -2
- package/dist/system-message.js +18 -4
- package/dist/system-message.test.js +28 -4
- package/dist/task-runner.js +143 -12
- package/dist/task-schedule.js +12 -0
- package/dist/task-schedule.test.js +149 -2
- package/dist/xai-auth-plugin.js +237 -0
- package/dist/xai-auth-state.js +233 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +6 -6
- package/skills/profano/SKILL.md +5 -3
- package/skills/spiceflow/SKILL.md +24 -1
- package/src/agent-model.e2e.test.ts +172 -4
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/cli-commands/send.ts +22 -3
- package/src/cli-commands/task.ts +32 -19
- package/src/cli-runner.test.ts +16 -1
- package/src/cli-runner.ts +88 -89
- package/src/commands/agent.ts +120 -84
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -1
- package/src/ipc-tools-plugin.ts +4 -3
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/message-formatting.test.ts +197 -2
- package/src/message-formatting.ts +98 -5
- package/src/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -0
- package/src/schema.sql +15 -0
- package/src/schema.ts +20 -0
- package/src/session-handler/event-stream-state.test.ts +490 -0
- package/src/session-handler/event-stream-state.ts +210 -0
- package/src/session-handler/model-utils.ts +1 -0
- package/src/session-handler/thread-session-runtime.ts +105 -44
- package/src/subagent-rate-limit-plugin.ts +12 -2
- package/src/system-message.test.ts +29 -4
- package/src/system-message.ts +20 -4
- package/src/task-runner.ts +168 -11
- package/src/task-schedule.test.ts +172 -2
- package/src/task-schedule.ts +23 -2
- package/src/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +293 -0
package/dist/system-message.js
CHANGED
|
@@ -374,7 +374,8 @@ see --help for options like voice, speed, etc.
|
|
|
374
374
|
|
|
375
375
|
## requesting files from the user
|
|
376
376
|
|
|
377
|
-
To ask the user to upload files from their device, use
|
|
377
|
+
To ask the user to upload files from their device, use \`kimaki_file_upload\`. This shows a native file picker dialog in Discord. The files are downloaded to the project's \`uploads/\` directory and the tool returns the local file paths.
|
|
378
|
+
You MUST call \`kimaki_file_upload\` LAST, after ALL text.
|
|
378
379
|
|
|
379
380
|
## archiving the current thread
|
|
380
381
|
|
|
@@ -492,6 +493,10 @@ Use \`--send-at\` to schedule a one-time or recurring task:
|
|
|
492
493
|
kimaki send --channel ${channelId} --prompt 'Reminder: review open PRs' --send-at '2026-03-01T09:00:00Z' --agent <current_agent>${parentSessionArg}${userArg}
|
|
493
494
|
kimaki send --channel ${channelId} --prompt 'Run weekly test suite and summarize failures' --send-at '0 9 * * 1' --agent <current_agent>${parentSessionArg}${userArg}
|
|
494
495
|
|
|
496
|
+
Use \`--pre-run '<command>'\` to check whether a scheduled task should start. Kimaki runs the command in the project directory. Exit code 0 starts the session and appends stdout to the prompt. Any other exit code skips that occurrence. Command output is written to the Kimaki log.
|
|
497
|
+
|
|
498
|
+
Scheduled tasks do not overlap by default. Add \`--allow-concurrency\` only when concurrent sessions from the same task are safe.
|
|
499
|
+
|
|
495
500
|
**ALWAYS pass \`--user\` when scheduling a task.** Discord only shows a thread in the left sidebar to its members. Without \`--user\`, kimaki does not ensure anyone is a member, so the task can fire completely unnoticed if the user never joined the thread or already left it. This applies to \`--channel\` and \`--thread\` scheduling alike.
|
|
496
501
|
|
|
497
502
|
ALL scheduling is in UTC. Dates must be UTC ISO format ending with \`Z\`. Cron expressions also fire in UTC (e.g. \`0 9 * * 1\` means 9:00 UTC every Monday).
|
|
@@ -501,6 +506,8 @@ When the user specifies a time without a timezone, ask them to confirm their tim
|
|
|
501
506
|
- \`--notify-only\` to create a reminder thread without auto-starting a session
|
|
502
507
|
- \`--worktree\` to create the scheduled thread as a worktree session (only if the user explicitly asks for a worktree)
|
|
503
508
|
- \`--agent\` and \`--model\` to control scheduled session behavior
|
|
509
|
+
- \`--pre-run\` to start only when a project command exits with code 0
|
|
510
|
+
- \`--allow-concurrency\` to permit overlapping runs from the same task
|
|
504
511
|
- \`--parent-session\` to pass this session as parent of the scheduled child
|
|
505
512
|
- \`--user\` to add a specific user to the scheduled thread (always pass this)
|
|
506
513
|
|
|
@@ -534,7 +541,7 @@ Notification strategy:
|
|
|
534
541
|
Manage scheduled tasks with:
|
|
535
542
|
|
|
536
543
|
kimaki task list
|
|
537
|
-
kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
|
|
544
|
+
kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--pre-run "command"] [--allow-concurrency true|false] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
|
|
538
545
|
kimaki task delete <id>
|
|
539
546
|
|
|
540
547
|
\`kimaki task list\` prints \`userId\`, \`agent\`, and \`model\` columns. A \`-\` in \`userId\` means nobody is added to the thread when that task fires, so the user may never see it. Fix it with \`kimaki task edit <id> --user '<discord-user-id>'\` instead of deleting and recreating the task. Change model or agent in place with \`--model\` / \`--agent\` (empty string clears the override). Do not read SQLite or recreate the task just to swap model.
|
|
@@ -827,9 +834,16 @@ Only ask questions when the request is genuinely ambiguous with multiple valid a
|
|
|
827
834
|
|
|
828
835
|
## ending conversations with options
|
|
829
836
|
|
|
830
|
-
|
|
837
|
+
You MUST write ALL user-visible text FIRST.
|
|
838
|
+
You MUST call \`question\` LAST, after ALL text parts.
|
|
839
|
+
NEVER call \`question\` before your text. Discord will hide the message.
|
|
840
|
+
|
|
841
|
+
The same rule applies to \`kimaki_action_buttons\` and \`kimaki_file_upload\`.
|
|
842
|
+
You MUST call them LAST, after ALL text.
|
|
843
|
+
|
|
844
|
+
ALWAYS use \`question\` when you ask the user a question. Do not write a numbered list in plain text.
|
|
831
845
|
|
|
832
|
-
IMPORTANT: Do NOT use
|
|
846
|
+
IMPORTANT: Do NOT use \`question\` to ask permission before doing work. Do the work first, then offer follow-ups.
|
|
833
847
|
|
|
834
848
|
Examples:
|
|
835
849
|
- After completing edits: offer "Commit changes?"
|
|
@@ -24,6 +24,16 @@ describe('system-message', () => {
|
|
|
24
24
|
expect(message).toContain('- failed commands');
|
|
25
25
|
expect(message).toContain('<callout accent="#f59e0b">');
|
|
26
26
|
});
|
|
27
|
+
test('requires interactive tools after all text, using exact tool names', () => {
|
|
28
|
+
const message = getOpencodeSystemMessage({
|
|
29
|
+
sessionId: 'ses_123',
|
|
30
|
+
});
|
|
31
|
+
expect(message).toContain('You MUST write ALL user-visible text FIRST');
|
|
32
|
+
expect(message).toContain('You MUST call `question` LAST');
|
|
33
|
+
expect(message).toContain('NEVER call `question` before your text');
|
|
34
|
+
expect(message).toContain('`kimaki_action_buttons`');
|
|
35
|
+
expect(message).toContain('`kimaki_file_upload`');
|
|
36
|
+
});
|
|
27
37
|
test('persists and reads session system prompt for command path', async () => {
|
|
28
38
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-system-'));
|
|
29
39
|
tempDirs.push(dataDir);
|
|
@@ -174,7 +184,8 @@ describe('system-message', () => {
|
|
|
174
184
|
|
|
175
185
|
## requesting files from the user
|
|
176
186
|
|
|
177
|
-
To ask the user to upload files from their device, use
|
|
187
|
+
To ask the user to upload files from their device, use \`kimaki_file_upload\`. This shows a native file picker dialog in Discord. The files are downloaded to the project's \`uploads/\` directory and the tool returns the local file paths.
|
|
188
|
+
You MUST call \`kimaki_file_upload\` LAST, after ALL text.
|
|
178
189
|
|
|
179
190
|
## archiving the current thread
|
|
180
191
|
|
|
@@ -295,6 +306,10 @@ describe('system-message', () => {
|
|
|
295
306
|
kimaki send --channel chan_123 --prompt 'Reminder: review open PRs' --send-at '2026-03-01T09:00:00Z' --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
|
|
296
307
|
kimaki send --channel chan_123 --prompt 'Run weekly test suite and summarize failures' --send-at '0 9 * * 1' --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
|
|
297
308
|
|
|
309
|
+
Use \`--pre-run '<command>'\` to check whether a scheduled task should start. Kimaki runs the command in the project directory. Exit code 0 starts the session and appends stdout to the prompt. Any other exit code skips that occurrence. Command output is written to the Kimaki log.
|
|
310
|
+
|
|
311
|
+
Scheduled tasks do not overlap by default. Add \`--allow-concurrency\` only when concurrent sessions from the same task are safe.
|
|
312
|
+
|
|
298
313
|
**ALWAYS pass \`--user\` when scheduling a task.** Discord only shows a thread in the left sidebar to its members. Without \`--user\`, kimaki does not ensure anyone is a member, so the task can fire completely unnoticed if the user never joined the thread or already left it. This applies to \`--channel\` and \`--thread\` scheduling alike.
|
|
299
314
|
|
|
300
315
|
ALL scheduling is in UTC. Dates must be UTC ISO format ending with \`Z\`. Cron expressions also fire in UTC (e.g. \`0 9 * * 1\` means 9:00 UTC every Monday).
|
|
@@ -304,6 +319,8 @@ describe('system-message', () => {
|
|
|
304
319
|
- \`--notify-only\` to create a reminder thread without auto-starting a session
|
|
305
320
|
- \`--worktree\` to create the scheduled thread as a worktree session (only if the user explicitly asks for a worktree)
|
|
306
321
|
- \`--agent\` and \`--model\` to control scheduled session behavior
|
|
322
|
+
- \`--pre-run\` to start only when a project command exits with code 0
|
|
323
|
+
- \`--allow-concurrency\` to permit overlapping runs from the same task
|
|
307
324
|
- \`--parent-session\` to pass this session as parent of the scheduled child
|
|
308
325
|
- \`--user\` to add a specific user to the scheduled thread (always pass this)
|
|
309
326
|
|
|
@@ -337,7 +354,7 @@ describe('system-message', () => {
|
|
|
337
354
|
Manage scheduled tasks with:
|
|
338
355
|
|
|
339
356
|
kimaki task list
|
|
340
|
-
kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
|
|
357
|
+
kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"] [--pre-run "command"] [--allow-concurrency true|false] [--user "<discord-user-id>"] [--model "provider/model"] [--agent "<agent>"]
|
|
341
358
|
kimaki task delete <id>
|
|
342
359
|
|
|
343
360
|
\`kimaki task list\` prints \`userId\`, \`agent\`, and \`model\` columns. A \`-\` in \`userId\` means nobody is added to the thread when that task fires, so the user may never see it. Fix it with \`kimaki task edit <id> --user '<discord-user-id>'\` instead of deleting and recreating the task. Change model or agent in place with \`--model\` / \`--agent\` (empty string clears the override). Do not read SQLite or recreate the task just to swap model.
|
|
@@ -782,9 +799,16 @@ describe('system-message', () => {
|
|
|
782
799
|
|
|
783
800
|
## ending conversations with options
|
|
784
801
|
|
|
785
|
-
|
|
802
|
+
You MUST write ALL user-visible text FIRST.
|
|
803
|
+
You MUST call \`question\` LAST, after ALL text parts.
|
|
804
|
+
NEVER call \`question\` before your text. Discord will hide the message.
|
|
805
|
+
|
|
806
|
+
The same rule applies to \`kimaki_action_buttons\` and \`kimaki_file_upload\`.
|
|
807
|
+
You MUST call them LAST, after ALL text.
|
|
808
|
+
|
|
809
|
+
ALWAYS use \`question\` when you ask the user a question. Do not write a numbered list in plain text.
|
|
786
810
|
|
|
787
|
-
IMPORTANT: Do NOT use
|
|
811
|
+
IMPORTANT: Do NOT use \`question\` to ask permission before doing work. Do the work first, then offer follow-ups.
|
|
788
812
|
|
|
789
813
|
Examples:
|
|
790
814
|
- After completing edits: offer "Commit changes?"
|
package/dist/task-runner.js
CHANGED
|
@@ -3,11 +3,15 @@ import { Routes } from 'discord.js';
|
|
|
3
3
|
import { createDiscordRest } from './discord-urls.js';
|
|
4
4
|
import { ensureThreadMember } from './discord-utils.js';
|
|
5
5
|
import YAML from 'yaml';
|
|
6
|
-
import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, getScheduledTask, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, } from './database.js';
|
|
6
|
+
import { claimScheduledTaskRunning, createScheduledTaskRun, failScheduledTaskRun, finishScheduledTaskRun, getDuePlannedScheduledTasks, getScheduledTask, getActiveScheduledTaskRuns, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, releaseScheduledTaskClaim, setScheduledTaskRunThread, } from './database.js';
|
|
7
|
+
import { execAsync } from './exec-async.js';
|
|
8
|
+
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
7
9
|
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
8
10
|
import { notifyError } from './sentry.js';
|
|
9
|
-
import { getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
|
|
11
|
+
import { appendTaskCommandOutput, getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
|
|
10
12
|
const taskLogger = createLogger(LogPrefix.TASK);
|
|
13
|
+
const MAX_SCHEDULED_PROMPT_LENGTH = 1_900;
|
|
14
|
+
const PENDING_RUN_TIMEOUT_MS = 120_000;
|
|
11
15
|
function isRecord(value) {
|
|
12
16
|
return typeof value === 'object' && value !== null;
|
|
13
17
|
}
|
|
@@ -20,7 +24,7 @@ function parseMessageId(value) {
|
|
|
20
24
|
}
|
|
21
25
|
return value.id;
|
|
22
26
|
}
|
|
23
|
-
async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
27
|
+
async function executeThreadScheduledTask({ rest, task, payload, prompt, runId, }) {
|
|
24
28
|
const marker = {
|
|
25
29
|
start: true,
|
|
26
30
|
scheduledKind: task.schedule_kind,
|
|
@@ -28,6 +32,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
28
32
|
// after execution, so the ID would be stale by the time the bot processes
|
|
29
33
|
// the Discord event and tries to insert a session_start_sources row.
|
|
30
34
|
...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
|
|
35
|
+
...(runId ? { scheduledTaskRunId: runId } : {}),
|
|
31
36
|
...(payload.agent ? { agent: payload.agent } : {}),
|
|
32
37
|
...(payload.model ? { model: payload.model } : {}),
|
|
33
38
|
...(payload.username ? { username: payload.username } : {}),
|
|
@@ -41,7 +46,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
41
46
|
const embed = [{ color: 0x2b2d31, footer: { text: YAML.stringify(marker) } }];
|
|
42
47
|
// Newline between prefix and prompt so leading /command detection can
|
|
43
48
|
// find the command on its own line.
|
|
44
|
-
const prefixedPrompt = `» **kimaki-cli:**\n${
|
|
49
|
+
const prefixedPrompt = `» **kimaki-cli:**\n${prompt}`;
|
|
45
50
|
// Re-join the user before posting, so the message they get notified about is
|
|
46
51
|
// in a thread they are already a member of. Works on archived threads too;
|
|
47
52
|
// the post below auto-unarchives.
|
|
@@ -69,8 +74,9 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
69
74
|
});
|
|
70
75
|
if (postResult instanceof Error)
|
|
71
76
|
return postResult;
|
|
77
|
+
return payload.threadId;
|
|
72
78
|
}
|
|
73
|
-
async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
79
|
+
async function executeChannelScheduledTask({ rest, task, payload, prompt, runId, }) {
|
|
74
80
|
const marker = payload.notifyOnly
|
|
75
81
|
? undefined
|
|
76
82
|
: {
|
|
@@ -78,6 +84,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
78
84
|
scheduledKind: task.schedule_kind,
|
|
79
85
|
// Only include scheduledTaskId for cron tasks (see thread variant comment)
|
|
80
86
|
...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
|
|
87
|
+
...(runId ? { scheduledTaskRunId: runId } : {}),
|
|
81
88
|
...(payload.worktreeName ? { worktree: payload.worktreeName } : {}),
|
|
82
89
|
...(payload.cwd ? { cwd: payload.cwd } : {}),
|
|
83
90
|
...(payload.agent ? { agent: payload.agent } : {}),
|
|
@@ -96,7 +103,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
96
103
|
const starterResult = await rest
|
|
97
104
|
.post(Routes.channelMessages(payload.channelId), {
|
|
98
105
|
body: {
|
|
99
|
-
content:
|
|
106
|
+
content: prompt,
|
|
100
107
|
embeds,
|
|
101
108
|
},
|
|
102
109
|
})
|
|
@@ -113,7 +120,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
113
120
|
cause: starterMessageId,
|
|
114
121
|
});
|
|
115
122
|
}
|
|
116
|
-
const threadName = (payload.name || getPromptPreview(
|
|
123
|
+
const threadName = (payload.name || getPromptPreview(prompt)).slice(0, 100);
|
|
117
124
|
const threadResult = await rest
|
|
118
125
|
.post(Routes.threads(payload.channelId, starterMessageId), {
|
|
119
126
|
body: {
|
|
@@ -128,15 +135,14 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
128
135
|
});
|
|
129
136
|
if (threadResult instanceof Error)
|
|
130
137
|
return threadResult;
|
|
131
|
-
if (!payload.userId) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
138
|
const threadIdResult = parseMessageId(threadResult);
|
|
135
139
|
if (threadIdResult instanceof Error) {
|
|
136
140
|
return new Error(`Invalid thread response for task ${task.id}`, {
|
|
137
141
|
cause: threadIdResult,
|
|
138
142
|
});
|
|
139
143
|
}
|
|
144
|
+
if (!payload.userId)
|
|
145
|
+
return threadIdResult;
|
|
140
146
|
const addMemberResult = await ensureThreadMember({
|
|
141
147
|
rest,
|
|
142
148
|
threadId: threadIdResult,
|
|
@@ -145,25 +151,111 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
145
151
|
if (addMemberResult instanceof Error) {
|
|
146
152
|
return new Error(`Failed to add user to scheduled thread for task ${task.id}`, { cause: addMemberResult });
|
|
147
153
|
}
|
|
154
|
+
return threadIdResult;
|
|
155
|
+
}
|
|
156
|
+
export async function runTaskCommand({ task, payload, }) {
|
|
157
|
+
if (!payload.preRunCommand)
|
|
158
|
+
return { kind: 'run', prompt: payload.prompt };
|
|
159
|
+
if (!task.project_directory) {
|
|
160
|
+
return new Error(`Task ${task.id} has a pre-run command but no project directory`);
|
|
161
|
+
}
|
|
162
|
+
const command = payload.preRunCommand;
|
|
163
|
+
const result = await execAsync(command, { cwd: task.project_directory }).catch((error) => error instanceof Error ? error : new Error(String(error)));
|
|
164
|
+
const stdout = isRecord(result) && typeof result.stdout === 'string'
|
|
165
|
+
? result.stdout
|
|
166
|
+
: '';
|
|
167
|
+
const stderr = isRecord(result) && typeof result.stderr === 'string'
|
|
168
|
+
? result.stderr
|
|
169
|
+
: '';
|
|
170
|
+
const exitCode = result instanceof Error && isRecord(result)
|
|
171
|
+
? result.code ?? 1
|
|
172
|
+
: 0;
|
|
173
|
+
if (stdout)
|
|
174
|
+
taskLogger.log(`[task-runner] task ${task.id} pre-run stdout:\n${stdout}`);
|
|
175
|
+
if (stderr)
|
|
176
|
+
taskLogger.log(`[task-runner] task ${task.id} pre-run stderr:\n${stderr}`);
|
|
177
|
+
taskLogger.log(`[task-runner] task ${task.id} pre-run exited with ${exitCode}`);
|
|
178
|
+
if (result instanceof Error)
|
|
179
|
+
return { kind: 'skip' };
|
|
180
|
+
const prompt = appendTaskCommandOutput({ prompt: payload.prompt, stdout });
|
|
181
|
+
if (prompt.length > MAX_SCHEDULED_PROMPT_LENGTH) {
|
|
182
|
+
return new Error(`Task ${task.id} prompt and pre-run stdout exceed ${MAX_SCHEDULED_PROMPT_LENGTH} characters`);
|
|
183
|
+
}
|
|
184
|
+
return { kind: 'run', prompt };
|
|
185
|
+
}
|
|
186
|
+
async function hasRunningSession(task) {
|
|
187
|
+
const runs = await getActiveScheduledTaskRuns(task.id);
|
|
188
|
+
if (runs.length === 0)
|
|
189
|
+
return false;
|
|
190
|
+
let active = false;
|
|
191
|
+
for (const run of runs) {
|
|
192
|
+
if (!run.session_id) {
|
|
193
|
+
if (Date.now() - run.started_at.getTime() < PENDING_RUN_TIMEOUT_MS) {
|
|
194
|
+
active = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
await failScheduledTaskRun({
|
|
198
|
+
runId: run.id,
|
|
199
|
+
error: 'Timed out waiting for scheduled session to start',
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!run.project_directory) {
|
|
204
|
+
active = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const getClient = await initializeOpencodeForDirectory(run.project_directory);
|
|
208
|
+
if (getClient instanceof Error)
|
|
209
|
+
return getClient;
|
|
210
|
+
const statusResponse = await getClient().session.status({
|
|
211
|
+
directory: run.project_directory,
|
|
212
|
+
}).catch((error) => new Error('Failed to check scheduled session status', {
|
|
213
|
+
cause: error,
|
|
214
|
+
}));
|
|
215
|
+
if (statusResponse instanceof Error)
|
|
216
|
+
return statusResponse;
|
|
217
|
+
if (statusResponse.error)
|
|
218
|
+
return new Error('Failed to check scheduled session status');
|
|
219
|
+
const status = statusResponse.data?.[run.session_id];
|
|
220
|
+
if (status && status.type !== 'idle') {
|
|
221
|
+
active = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!status && Date.now() - run.started_at.getTime() < PENDING_RUN_TIMEOUT_MS) {
|
|
225
|
+
active = true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
await finishScheduledTaskRun({ runId: run.id, status: 'completed' });
|
|
229
|
+
}
|
|
230
|
+
return active;
|
|
148
231
|
}
|
|
149
|
-
async function executeScheduledTask({ rest, task, }) {
|
|
232
|
+
async function executeScheduledTask({ rest, task, runId, }) {
|
|
150
233
|
const payloadResult = parseScheduledTaskPayload(task.payload_json);
|
|
151
234
|
if (payloadResult instanceof Error) {
|
|
152
235
|
return new Error(`Task ${task.id} has invalid payload`, {
|
|
153
236
|
cause: payloadResult,
|
|
154
237
|
});
|
|
155
238
|
}
|
|
239
|
+
const commandResult = await runTaskCommand({ task, payload: payloadResult });
|
|
240
|
+
if (commandResult instanceof Error)
|
|
241
|
+
return commandResult;
|
|
242
|
+
if (commandResult.kind === 'skip')
|
|
243
|
+
return { kind: 'condition-not-met' };
|
|
156
244
|
if (payloadResult.kind === 'thread') {
|
|
157
245
|
return executeThreadScheduledTask({
|
|
158
246
|
rest,
|
|
159
247
|
task,
|
|
160
248
|
payload: payloadResult,
|
|
249
|
+
prompt: commandResult.prompt,
|
|
250
|
+
runId,
|
|
161
251
|
});
|
|
162
252
|
}
|
|
163
253
|
return executeChannelScheduledTask({
|
|
164
254
|
rest,
|
|
165
255
|
task,
|
|
166
256
|
payload: payloadResult,
|
|
257
|
+
prompt: commandResult.prompt,
|
|
258
|
+
runId,
|
|
167
259
|
});
|
|
168
260
|
}
|
|
169
261
|
async function finalizeSuccessfulTask({ task, completedAt, }) {
|
|
@@ -234,7 +326,32 @@ async function processDueTask({ rest, task, }) {
|
|
|
234
326
|
if (!claimed) {
|
|
235
327
|
return { kind: 'skipped' };
|
|
236
328
|
}
|
|
237
|
-
const
|
|
329
|
+
const payload = parseScheduledTaskPayload(task.payload_json);
|
|
330
|
+
if (payload instanceof Error) {
|
|
331
|
+
await finalizeFailedTask({ task, failedAt: new Date(), error: payload });
|
|
332
|
+
return { kind: 'failed', error: payload };
|
|
333
|
+
}
|
|
334
|
+
const activeSession = payload.allowConcurrency
|
|
335
|
+
? false
|
|
336
|
+
: await hasRunningSession(task);
|
|
337
|
+
if (activeSession instanceof Error) {
|
|
338
|
+
await releaseScheduledTaskClaim(task.id);
|
|
339
|
+
taskLogger.warn(`[task-runner] could not check concurrency for task ${task.id}: ${formatErrorWithStack(activeSession)}`);
|
|
340
|
+
return { kind: 'failed', error: activeSession };
|
|
341
|
+
}
|
|
342
|
+
if (activeSession) {
|
|
343
|
+
if (task.schedule_kind === 'cron') {
|
|
344
|
+
await finalizeSuccessfulTask({ task, completedAt: new Date() });
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
await releaseScheduledTaskClaim(task.id);
|
|
348
|
+
}
|
|
349
|
+
return { kind: 'concurrency-blocked' };
|
|
350
|
+
}
|
|
351
|
+
const runId = task.schedule_kind === 'cron'
|
|
352
|
+
? await createScheduledTaskRun({ taskId: task.id, startedAt })
|
|
353
|
+
: undefined;
|
|
354
|
+
const executeResult = await executeScheduledTask({ rest, task, runId });
|
|
238
355
|
const finishedAt = new Date();
|
|
239
356
|
if (executeResult instanceof Error) {
|
|
240
357
|
taskLogger.warn(`[task-runner] task ${task.id} failed: ${formatErrorWithStack(executeResult)}`);
|
|
@@ -243,8 +360,22 @@ async function processDueTask({ rest, task, }) {
|
|
|
243
360
|
failedAt: finishedAt,
|
|
244
361
|
error: executeResult,
|
|
245
362
|
});
|
|
363
|
+
if (runId)
|
|
364
|
+
await failScheduledTaskRun({ runId, error: executeResult.message });
|
|
246
365
|
return { kind: 'failed', error: executeResult };
|
|
247
366
|
}
|
|
367
|
+
if (!(typeof executeResult === 'string' || executeResult === null)) {
|
|
368
|
+
if (runId)
|
|
369
|
+
await finishScheduledTaskRun({ runId, status: 'skipped' });
|
|
370
|
+
await finalizeSuccessfulTask({ task, completedAt: finishedAt });
|
|
371
|
+
return executeResult;
|
|
372
|
+
}
|
|
373
|
+
if (executeResult && runId) {
|
|
374
|
+
await setScheduledTaskRunThread({ runId, threadId: executeResult });
|
|
375
|
+
}
|
|
376
|
+
else if (runId) {
|
|
377
|
+
await finishScheduledTaskRun({ runId, status: 'completed' });
|
|
378
|
+
}
|
|
248
379
|
await finalizeSuccessfulTask({ task, completedAt: finishedAt });
|
|
249
380
|
return { kind: 'success' };
|
|
250
381
|
}
|
package/dist/task-schedule.js
CHANGED
|
@@ -106,6 +106,12 @@ export function getNextCronRun({ cronExpr, timezone, from, }) {
|
|
|
106
106
|
export function serializeScheduledTaskPayload(payload) {
|
|
107
107
|
return JSON.stringify(payload);
|
|
108
108
|
}
|
|
109
|
+
export function appendTaskCommandOutput({ prompt, stdout, }) {
|
|
110
|
+
const output = stdout.trim();
|
|
111
|
+
if (!output)
|
|
112
|
+
return prompt;
|
|
113
|
+
return `${prompt}\n\n## Pre-run command output\n\n${output}`;
|
|
114
|
+
}
|
|
109
115
|
function isRecord(value) {
|
|
110
116
|
return typeof value === 'object' && value !== null;
|
|
111
117
|
}
|
|
@@ -135,6 +141,8 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
135
141
|
return new Error('Task payload must be an object');
|
|
136
142
|
}
|
|
137
143
|
const kind = asString(parsed.kind);
|
|
144
|
+
const preRunCommand = asString(parsed.preRunCommand);
|
|
145
|
+
const allowConcurrency = parsed.allowConcurrency === true;
|
|
138
146
|
if (kind === 'thread') {
|
|
139
147
|
const threadId = asString(parsed.threadId);
|
|
140
148
|
const prompt = asString(parsed.prompt);
|
|
@@ -159,6 +167,8 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
159
167
|
permissions,
|
|
160
168
|
injectionGuardPatterns,
|
|
161
169
|
parentSessionId,
|
|
170
|
+
preRunCommand,
|
|
171
|
+
allowConcurrency,
|
|
162
172
|
};
|
|
163
173
|
}
|
|
164
174
|
if (kind === 'channel') {
|
|
@@ -194,6 +204,8 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
194
204
|
permissions,
|
|
195
205
|
injectionGuardPatterns,
|
|
196
206
|
parentSessionId,
|
|
207
|
+
preRunCommand,
|
|
208
|
+
allowConcurrency,
|
|
197
209
|
};
|
|
198
210
|
}
|
|
199
211
|
return new Error('Task payload has unknown kind');
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// Tests for scheduled task date/cron parsing and UTC validation rules.
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { afterAll, afterEach, describe, expect, test } from 'vitest';
|
|
6
|
+
import { completeScheduledTaskRunsForSession, createScheduledTask, createScheduledTaskRun, getActiveScheduledTaskRuns, getScheduledTask, startScheduledTaskRunSession, } from './database.js';
|
|
7
|
+
import { closeDb } from './db.js';
|
|
8
|
+
import { runTaskCommand } from './task-runner.js';
|
|
9
|
+
import { appendTaskCommandOutput, parseScheduledTaskPayload, parseSendAtValue, } from './task-schedule.js';
|
|
4
10
|
describe('parseSendAtValue', () => {
|
|
5
11
|
test('accepts UTC ISO date ending with Z', () => {
|
|
6
12
|
const now = new Date('2026-02-22T13:00:00Z');
|
|
@@ -69,3 +75,144 @@ describe('parseSendAtValue', () => {
|
|
|
69
75
|
expect(result.nextRunAt.toISOString()).toBe('2026-02-23T09:00:00.000Z');
|
|
70
76
|
});
|
|
71
77
|
});
|
|
78
|
+
describe('scheduled task execution options', () => {
|
|
79
|
+
const tempDirectories = [];
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
82
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
test('defaults old payloads to no command and no concurrency', () => {
|
|
86
|
+
const payload = parseScheduledTaskPayload(JSON.stringify({
|
|
87
|
+
kind: 'thread',
|
|
88
|
+
threadId: 'thread-1',
|
|
89
|
+
prompt: 'Handle support requests',
|
|
90
|
+
}));
|
|
91
|
+
expect(payload).toMatchInlineSnapshot(`
|
|
92
|
+
{
|
|
93
|
+
"agent": null,
|
|
94
|
+
"allowConcurrency": false,
|
|
95
|
+
"injectionGuardPatterns": null,
|
|
96
|
+
"kind": "thread",
|
|
97
|
+
"model": null,
|
|
98
|
+
"parentSessionId": null,
|
|
99
|
+
"permissions": null,
|
|
100
|
+
"preRunCommand": null,
|
|
101
|
+
"prompt": "Handle support requests",
|
|
102
|
+
"threadId": "thread-1",
|
|
103
|
+
"userId": null,
|
|
104
|
+
"username": null,
|
|
105
|
+
}
|
|
106
|
+
`);
|
|
107
|
+
});
|
|
108
|
+
test('adds command stdout to the prompt', () => {
|
|
109
|
+
expect(appendTaskCommandOutput({
|
|
110
|
+
prompt: 'Handle support requests',
|
|
111
|
+
stdout: '{"ticketId":"ticket-123"}\n',
|
|
112
|
+
})).toMatchInlineSnapshot(`
|
|
113
|
+
"Handle support requests
|
|
114
|
+
|
|
115
|
+
## Pre-run command output
|
|
116
|
+
|
|
117
|
+
{"ticketId":"ticket-123"}"
|
|
118
|
+
`);
|
|
119
|
+
});
|
|
120
|
+
test('runs the pre-run command in the project directory', async () => {
|
|
121
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-task-'));
|
|
122
|
+
tempDirectories.push(directory);
|
|
123
|
+
fs.writeFileSync(path.join(directory, 'should-run.ts'), "process.stdout.write(JSON.stringify({ cwd: process.cwd(), ticketId: 'ticket-123' }))\n");
|
|
124
|
+
const taskId = await createScheduledTask({
|
|
125
|
+
scheduleKind: 'cron',
|
|
126
|
+
cronExpr: '*/5 * * * *',
|
|
127
|
+
timezone: 'UTC',
|
|
128
|
+
nextRunAt: new Date('2026-08-12T18:00:00Z'),
|
|
129
|
+
payloadJson: JSON.stringify({
|
|
130
|
+
kind: 'thread',
|
|
131
|
+
threadId: 'thread-1',
|
|
132
|
+
prompt: 'Handle support requests',
|
|
133
|
+
preRunCommand: 'tsx should-run.ts',
|
|
134
|
+
}),
|
|
135
|
+
promptPreview: 'Handle support requests',
|
|
136
|
+
projectDirectory: directory,
|
|
137
|
+
});
|
|
138
|
+
const task = await getScheduledTask(taskId);
|
|
139
|
+
if (!task)
|
|
140
|
+
throw new Error('Expected scheduled task');
|
|
141
|
+
const payload = parseScheduledTaskPayload(task.payload_json);
|
|
142
|
+
if (payload instanceof Error)
|
|
143
|
+
throw payload;
|
|
144
|
+
const result = await runTaskCommand({ task, payload });
|
|
145
|
+
const realDirectory = fs.realpathSync(directory);
|
|
146
|
+
expect(result instanceof Error || result.kind === 'skip' ? result : {
|
|
147
|
+
...result,
|
|
148
|
+
prompt: result.prompt.replace(realDirectory, '<project-directory>'),
|
|
149
|
+
}).toMatchInlineSnapshot(`
|
|
150
|
+
{
|
|
151
|
+
"kind": "run",
|
|
152
|
+
"prompt": "Handle support requests
|
|
153
|
+
|
|
154
|
+
## Pre-run command output
|
|
155
|
+
|
|
156
|
+
{"cwd":"<project-directory>","ticketId":"ticket-123"}",
|
|
157
|
+
}
|
|
158
|
+
`);
|
|
159
|
+
});
|
|
160
|
+
test('skips the task when the pre-run command exits nonzero', async () => {
|
|
161
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-task-'));
|
|
162
|
+
tempDirectories.push(directory);
|
|
163
|
+
const taskId = await createScheduledTask({
|
|
164
|
+
scheduleKind: 'cron',
|
|
165
|
+
cronExpr: '*/5 * * * *',
|
|
166
|
+
timezone: 'UTC',
|
|
167
|
+
nextRunAt: new Date('2026-08-12T18:00:00Z'),
|
|
168
|
+
payloadJson: JSON.stringify({
|
|
169
|
+
kind: 'thread',
|
|
170
|
+
threadId: 'thread-skip',
|
|
171
|
+
prompt: 'Handle support requests',
|
|
172
|
+
preRunCommand: "node -e \"process.stdout.write('nothing pending'); process.exit(1)\"",
|
|
173
|
+
}),
|
|
174
|
+
promptPreview: 'Handle support requests',
|
|
175
|
+
projectDirectory: directory,
|
|
176
|
+
});
|
|
177
|
+
const task = await getScheduledTask(taskId);
|
|
178
|
+
if (!task)
|
|
179
|
+
throw new Error('Expected scheduled task');
|
|
180
|
+
const payload = parseScheduledTaskPayload(task.payload_json);
|
|
181
|
+
if (payload instanceof Error)
|
|
182
|
+
throw payload;
|
|
183
|
+
expect(await runTaskCommand({ task, payload })).toMatchInlineSnapshot(`
|
|
184
|
+
{
|
|
185
|
+
"kind": "skip",
|
|
186
|
+
}
|
|
187
|
+
`);
|
|
188
|
+
});
|
|
189
|
+
test('tracks active runs until their OpenCode session completes', async () => {
|
|
190
|
+
const taskId = await createScheduledTask({
|
|
191
|
+
scheduleKind: 'cron',
|
|
192
|
+
cronExpr: '*/5 * * * *',
|
|
193
|
+
timezone: 'UTC',
|
|
194
|
+
nextRunAt: new Date('2026-08-12T18:00:00Z'),
|
|
195
|
+
payloadJson: JSON.stringify({
|
|
196
|
+
kind: 'thread',
|
|
197
|
+
threadId: 'thread-2',
|
|
198
|
+
prompt: 'Handle support requests',
|
|
199
|
+
}),
|
|
200
|
+
promptPreview: 'Handle support requests',
|
|
201
|
+
});
|
|
202
|
+
const runId = await createScheduledTaskRun({
|
|
203
|
+
taskId,
|
|
204
|
+
startedAt: new Date('2026-08-12T18:00:00Z'),
|
|
205
|
+
});
|
|
206
|
+
await startScheduledTaskRunSession({
|
|
207
|
+
runId,
|
|
208
|
+
sessionId: 'session-1',
|
|
209
|
+
projectDirectory: '/project',
|
|
210
|
+
});
|
|
211
|
+
expect(await getActiveScheduledTaskRuns(taskId)).toHaveLength(1);
|
|
212
|
+
await completeScheduledTaskRunsForSession('session-1');
|
|
213
|
+
expect(await getActiveScheduledTaskRuns(taskId)).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
afterAll(async () => {
|
|
217
|
+
await closeDb();
|
|
218
|
+
});
|