kimaki 0.20.1 → 0.22.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 +6 -4
- package/dist/cli-commands/project.js +148 -24
- package/dist/cli-commands/send.js +48 -3
- package/dist/cli-runner.js +86 -7
- package/dist/cli-runner.test.js +28 -1
- package/dist/commands/btw.js +7 -3
- package/dist/commands/new-worktree.js +14 -2
- package/dist/commands/session.js +66 -13
- package/dist/commands/tasks.js +104 -19
- package/dist/commands/user-command.js +3 -2
- package/dist/database.js +14 -0
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +65 -9
- package/dist/interaction-handler.js +6 -15
- package/dist/message-preprocessing.js +9 -2
- package/dist/queue-advanced-e2e-setup.js +22 -0
- package/dist/queue-delete-message.e2e.test.js +75 -0
- package/dist/schema.js +3 -0
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +49 -1
- package/dist/system-message.js +50 -63
- package/dist/system-message.test.js +65 -61
- package/dist/task-runner.js +18 -3
- package/dist/task-schedule.js +4 -0
- package/dist/thread-message-queue.e2e.test.js +4 -4
- package/dist/voice-message.e2e.test.js +1 -1
- package/dist/worktree-lifecycle.e2e.test.js +70 -1
- package/dist/worktrees.js +63 -0
- package/package.json +11 -4
- package/skills/egaki/SKILL.md +26 -80
- package/skills/goke/SKILL.md +27 -20
- package/skills/holocron/SKILL.md +72 -8
- package/skills/new-skill/SKILL.md +87 -0
- package/skills/playwriter/SKILL.md +4 -4
- package/skills/spiceflow/SKILL.md +2 -0
- package/src/agent-model.e2e.test.ts +10 -4
- package/src/cli-commands/project.ts +183 -26
- package/src/cli-commands/send.ts +64 -5
- package/src/cli-runner.test.ts +39 -1
- package/src/cli-runner.ts +115 -6
- package/src/commands/btw.ts +9 -2
- package/src/commands/new-worktree.ts +14 -1
- package/src/commands/session.ts +79 -17
- package/src/commands/tasks.ts +119 -19
- package/src/commands/user-command.ts +3 -2
- package/src/database.ts +24 -0
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +80 -8
- package/src/interaction-handler.ts +7 -19
- package/src/message-preprocessing.ts +11 -2
- package/src/queue-advanced-e2e-setup.ts +23 -0
- package/src/queue-delete-message.e2e.test.ts +102 -0
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/thread-runtime-state.ts +19 -0
- package/src/session-handler/thread-session-runtime.ts +75 -0
- package/src/system-message.test.ts +73 -61
- package/src/system-message.ts +63 -62
- package/src/task-runner.ts +34 -3
- package/src/task-schedule.ts +6 -0
- package/src/thread-message-queue.e2e.test.ts +4 -4
- package/src/voice-message.e2e.test.ts +1 -1
- package/src/worktree-lifecycle.e2e.test.ts +88 -1
- package/src/worktrees.ts +74 -0
|
@@ -195,26 +195,14 @@ export function registerInteractionHandler({
|
|
|
195
195
|
// Multi-machine routing: only handle interactions for channels owned
|
|
196
196
|
// by this machine (have a project directory configured in local db).
|
|
197
197
|
// If not owned, silently return so the other machine handles it.
|
|
198
|
+
// Setup commands (create-new-project, add-project) bypass this check
|
|
199
|
+
// because they are designed to run from any channel — they create new
|
|
200
|
+
// project channels rather than requiring one to already exist.
|
|
201
|
+
const isSetupCommand =
|
|
202
|
+
interaction.isChatInputCommand() &&
|
|
203
|
+
SETUP_COMMANDS.has(interaction.commandName)
|
|
198
204
|
const owned = await isInteractionOwnedByThisMachine(interaction)
|
|
199
|
-
if (!owned) {
|
|
200
|
-
// Setup commands get a helpful reply instead of silent ignore.
|
|
201
|
-
// Both machines may race to reply; one wins, the other silently
|
|
202
|
-
// fails (interaction tokens are single-use). The message is the
|
|
203
|
-
// same from either machine so the race is harmless.
|
|
204
|
-
if (
|
|
205
|
-
interaction.isChatInputCommand() &&
|
|
206
|
-
SETUP_COMMANDS.has(interaction.commandName)
|
|
207
|
-
) {
|
|
208
|
-
await interaction.reply({
|
|
209
|
-
content:
|
|
210
|
-
'Run this command in an existing Kimaki project channel.\nTo add a new project channel from the terminal, run:\n```\nkimaki project add /path/to/folder\n```',
|
|
211
|
-
flags: MessageFlags.Ephemeral,
|
|
212
|
-
}).catch(() => {
|
|
213
|
-
// Another machine already responded, safe to ignore
|
|
214
|
-
})
|
|
215
|
-
return
|
|
216
|
-
}
|
|
217
|
-
|
|
205
|
+
if (!owned && !isSetupCommand) {
|
|
218
206
|
interactionLogger.log(
|
|
219
207
|
`[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`,
|
|
220
208
|
)
|
|
@@ -390,10 +390,18 @@ export async function preprocessNewSessionMessage({
|
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
const qs = extractQueueSuffix(prompt)
|
|
393
|
+
|
|
394
|
+
const fileAttachments = await getFileAttachments(message)
|
|
395
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
396
|
+
const finalPrompt = textAttachmentsContent
|
|
397
|
+
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
398
|
+
: qs.prompt
|
|
399
|
+
|
|
393
400
|
if (
|
|
394
401
|
shouldSkipEmptyPrompt({
|
|
395
402
|
message,
|
|
396
|
-
prompt:
|
|
403
|
+
prompt: finalPrompt,
|
|
404
|
+
images: fileAttachments,
|
|
397
405
|
hasVoiceAttachment,
|
|
398
406
|
})
|
|
399
407
|
) {
|
|
@@ -401,7 +409,8 @@ export async function preprocessNewSessionMessage({
|
|
|
401
409
|
}
|
|
402
410
|
|
|
403
411
|
return {
|
|
404
|
-
prompt:
|
|
412
|
+
prompt: finalPrompt,
|
|
413
|
+
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
405
414
|
repliedMessage,
|
|
406
415
|
permissionRules,
|
|
407
416
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
@@ -127,6 +127,28 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
127
127
|
},
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
const slowBusyMatcher: DeterministicMatcher = {
|
|
131
|
+
id: 'slow-busy-reply',
|
|
132
|
+
priority: 100,
|
|
133
|
+
when: {
|
|
134
|
+
latestUserTextIncludes: 'SLOW_BUSY_MARKER',
|
|
135
|
+
},
|
|
136
|
+
then: {
|
|
137
|
+
parts: [
|
|
138
|
+
{ type: 'stream-start', warnings: [] },
|
|
139
|
+
{ type: 'text-start', id: 'slow-busy' },
|
|
140
|
+
{ type: 'text-delta', id: 'slow-busy', delta: 'slow-busy-reply' },
|
|
141
|
+
{ type: 'text-end', id: 'slow-busy' },
|
|
142
|
+
{
|
|
143
|
+
type: 'finish',
|
|
144
|
+
finishReason: 'stop',
|
|
145
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
partDelaysMs: [0, 0, 0, 2_000, 0],
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
130
152
|
const toolFollowupMatcher: DeterministicMatcher = {
|
|
131
153
|
id: 'tool-followup',
|
|
132
154
|
priority: 50,
|
|
@@ -755,6 +777,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
755
777
|
|
|
756
778
|
return [
|
|
757
779
|
slowAbortMatcher,
|
|
780
|
+
slowBusyMatcher,
|
|
758
781
|
typingRepulseMatcher,
|
|
759
782
|
pluginTimeoutSleepMatcher,
|
|
760
783
|
actionButtonClickFollowupMatcher,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// E2e tests for deleting queued Discord messages.
|
|
2
|
+
// Covers the MessageDelete path that removes pending local queue items before drain.
|
|
3
|
+
|
|
4
|
+
import { describe, test, expect } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
setupQueueAdvancedSuite,
|
|
7
|
+
TEST_USER_ID,
|
|
8
|
+
} from './queue-advanced-e2e-setup.js'
|
|
9
|
+
import {
|
|
10
|
+
waitForBotMessageContaining,
|
|
11
|
+
waitForFooterMessage,
|
|
12
|
+
waitForThreadState,
|
|
13
|
+
} from './test-utils.js'
|
|
14
|
+
|
|
15
|
+
const TEXT_CHANNEL_ID = '200000000000001071'
|
|
16
|
+
|
|
17
|
+
const e2eTest = describe
|
|
18
|
+
|
|
19
|
+
e2eTest('queue delete message', () => {
|
|
20
|
+
const ctx = setupQueueAdvancedSuite({
|
|
21
|
+
channelId: TEXT_CHANNEL_ID,
|
|
22
|
+
channelName: 'queue-delete-message-e2e',
|
|
23
|
+
dirName: 'queue-delete-message-e2e',
|
|
24
|
+
username: 'queue-delete-tester',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test(
|
|
28
|
+
'deleting a queued Discord message removes it from queue',
|
|
29
|
+
async () => {
|
|
30
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
31
|
+
content: 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
35
|
+
timeout: 4_000,
|
|
36
|
+
predicate: (t) => {
|
|
37
|
+
return t.name === 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup'
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
const th = ctx.discord.thread(thread.id)
|
|
41
|
+
|
|
42
|
+
await th.waitForBotReply({ timeout: 4_000 })
|
|
43
|
+
|
|
44
|
+
const queuedMsg = await th.user(TEST_USER_ID).sendMessage({
|
|
45
|
+
content: 'Reply with exactly: deleted-queue. queue',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
await waitForThreadState({
|
|
49
|
+
threadId: thread.id,
|
|
50
|
+
predicate: (state) => {
|
|
51
|
+
return state.queueItems.some((item) => {
|
|
52
|
+
return item.sourceMessageId === queuedMsg.id
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
timeout: 4_000,
|
|
56
|
+
description: 'queue has item to delete',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await th.user(TEST_USER_ID).deleteMessage({ messageId: queuedMsg.id })
|
|
60
|
+
|
|
61
|
+
await waitForThreadState({
|
|
62
|
+
threadId: thread.id,
|
|
63
|
+
predicate: (state) => {
|
|
64
|
+
return !state.queueItems.some((item) => {
|
|
65
|
+
return item.sourceMessageId === queuedMsg.id
|
|
66
|
+
})
|
|
67
|
+
},
|
|
68
|
+
timeout: 4_000,
|
|
69
|
+
description: 'queued item removed after Discord delete',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await waitForBotMessageContaining({
|
|
73
|
+
discord: ctx.discord,
|
|
74
|
+
threadId: thread.id,
|
|
75
|
+
text: 'removed message from queue',
|
|
76
|
+
timeout: 4_000,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await waitForFooterMessage({
|
|
80
|
+
discord: ctx.discord,
|
|
81
|
+
threadId: thread.id,
|
|
82
|
+
timeout: 8_000,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
86
|
+
"--- from: user (queue-delete-tester)
|
|
87
|
+
SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup
|
|
88
|
+
--- from: assistant (TestBot)
|
|
89
|
+
*using deterministic-provider/deterministic-v2*
|
|
90
|
+
Queued at position 1. Edit or delete your message to update the queue
|
|
91
|
+
⬦ **queue-delete-tester** removed message from queue
|
|
92
|
+
⬥ slow-busy-reply
|
|
93
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
94
|
+
`)
|
|
95
|
+
const finalText = await th.text()
|
|
96
|
+
expect(finalText).not.toContain(
|
|
97
|
+
'» **queue-delete-tester:** Reply with exactly: deleted-queue',
|
|
98
|
+
)
|
|
99
|
+
},
|
|
100
|
+
12_000,
|
|
101
|
+
)
|
|
102
|
+
})
|
package/src/schema.sql
CHANGED
package/src/schema.ts
CHANGED
|
@@ -29,6 +29,9 @@ export const thread_sessions = sqliteCore.sqliteTable('thread_sessions', {
|
|
|
29
29
|
session_id: sqliteCore.text('session_id').notNull(),
|
|
30
30
|
source: sqliteCore.text('source', { enum: ['kimaki', 'external_poll'] }).notNull().default('kimaki'),
|
|
31
31
|
last_synced_name: sqliteCore.text('last_synced_name'),
|
|
32
|
+
// Parent OpenCode session that spawned this thread via kimaki send --parent-session.
|
|
33
|
+
// Survives bot restarts so child multi-turn system prompts keep the parent ID.
|
|
34
|
+
parent_session_id: sqliteCore.text('parent_session_id'),
|
|
32
35
|
created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
|
|
33
36
|
})
|
|
34
37
|
|
|
@@ -51,6 +51,10 @@ export type QueuedMessage = {
|
|
|
51
51
|
// Written to a temp config file after session creation so the plugin
|
|
52
52
|
// can check per-session whether to scan tool outputs.
|
|
53
53
|
injectionGuardPatterns?: string[]
|
|
54
|
+
// Parent OpenCode session ID from `kimaki send --parent-session`.
|
|
55
|
+
// Applied once on first session create so the child system message can
|
|
56
|
+
// expose how to message the parent when the user asks.
|
|
57
|
+
parentSessionId?: string
|
|
54
58
|
// Discord message ID and thread ID of the source message. Embedded in
|
|
55
59
|
// <discord-user> synthetic context so the external sync loop can detect
|
|
56
60
|
// messages that originated from Discord and skip re-mirroring them.
|
|
@@ -82,6 +86,11 @@ export type ThreadRunState = {
|
|
|
82
86
|
sessionUsername: string | undefined
|
|
83
87
|
sessionUserId: string | undefined
|
|
84
88
|
|
|
89
|
+
// Parent OpenCode session that spawned this thread via
|
|
90
|
+
// `kimaki send --parent-session`. Set once on first ingress and reused for
|
|
91
|
+
// the session-stable system prompt.
|
|
92
|
+
parentSessionId: string | undefined
|
|
93
|
+
|
|
85
94
|
// FIFO queue of pending inputs waiting for kimaki-local dispatch.
|
|
86
95
|
// Normal user messages default to opencode queue mode; this queue is
|
|
87
96
|
// for explicit local-queue flows (for example /queue).
|
|
@@ -107,6 +116,7 @@ export function initialThreadState(): ThreadRunState {
|
|
|
107
116
|
sessionId: undefined,
|
|
108
117
|
sessionUsername: undefined,
|
|
109
118
|
sessionUserId: undefined,
|
|
119
|
+
parentSessionId: undefined,
|
|
110
120
|
queueItems: [],
|
|
111
121
|
sentPartIds: new Set(),
|
|
112
122
|
}
|
|
@@ -180,6 +190,15 @@ export function setSessionUserId(threadId: string, userId: string): void {
|
|
|
180
190
|
})
|
|
181
191
|
}
|
|
182
192
|
|
|
193
|
+
export function setParentSessionId(threadId: string, parentSessionId: string): void {
|
|
194
|
+
updateThread(threadId, (t) => {
|
|
195
|
+
if (t.parentSessionId) {
|
|
196
|
+
return t
|
|
197
|
+
}
|
|
198
|
+
return { ...t, parentSessionId }
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
183
202
|
export function enqueueItem(threadId: string, item: QueuedMessage): void {
|
|
184
203
|
updateThread(threadId, (t) => ({
|
|
185
204
|
...t,
|
|
@@ -50,6 +50,8 @@ import {
|
|
|
50
50
|
setPartMessage,
|
|
51
51
|
getThreadSession,
|
|
52
52
|
setThreadSession,
|
|
53
|
+
getThreadParentSessionId,
|
|
54
|
+
setThreadParentSessionId,
|
|
53
55
|
getThreadWorktreeOrWorkspace,
|
|
54
56
|
setSessionAgent,
|
|
55
57
|
clearSessionModel,
|
|
@@ -557,6 +559,12 @@ export type IngressInput = {
|
|
|
557
559
|
permissions?: string[]
|
|
558
560
|
permissionRules?: PermissionRuleset
|
|
559
561
|
injectionGuardPatterns?: string[]
|
|
562
|
+
/**
|
|
563
|
+
* Parent OpenCode session ID from explicit `kimaki send --parent-session` only.
|
|
564
|
+
* Stored once on first ingress and injected into the child system message.
|
|
565
|
+
* Never set for /btw, /fork, or task/subagent children (keeps system prompt cache).
|
|
566
|
+
*/
|
|
567
|
+
parentSessionId?: string
|
|
560
568
|
sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }
|
|
561
569
|
/** Optional guard for retries: skip enqueue when session has changed. */
|
|
562
570
|
expectedSessionId?: string
|
|
@@ -3079,6 +3087,7 @@ export class ThreadSessionRuntime {
|
|
|
3079
3087
|
agents: availableAgents,
|
|
3080
3088
|
username: this.state?.sessionUsername || input.username,
|
|
3081
3089
|
userId: this.state?.sessionUserId || input.userId,
|
|
3090
|
+
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
3082
3091
|
}),
|
|
3083
3092
|
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
|
3084
3093
|
...(modelField ? { model: modelField } : {}),
|
|
@@ -3132,6 +3141,7 @@ export class ThreadSessionRuntime {
|
|
|
3132
3141
|
permissions: input.permissions,
|
|
3133
3142
|
permissionRules: input.permissionRules,
|
|
3134
3143
|
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
3144
|
+
parentSessionId: input.parentSessionId,
|
|
3135
3145
|
sourceMessageId: input.sourceMessageId,
|
|
3136
3146
|
sourceThreadId: input.sourceThreadId,
|
|
3137
3147
|
repliedMessage: input.repliedMessage,
|
|
@@ -3182,6 +3192,9 @@ export class ThreadSessionRuntime {
|
|
|
3182
3192
|
async enqueueIncoming(input: IngressInput): Promise<EnqueueResult> {
|
|
3183
3193
|
threadState.setSessionUsername(this.threadId, input.username)
|
|
3184
3194
|
threadState.setSessionUserId(this.threadId, input.userId)
|
|
3195
|
+
await this.ensureParentSessionId({
|
|
3196
|
+
parentSessionId: input.parentSessionId,
|
|
3197
|
+
})
|
|
3185
3198
|
|
|
3186
3199
|
// When a preprocessor is provided, we must resolve it inside
|
|
3187
3200
|
// dispatchAction before we know the final mode for routing.
|
|
@@ -3204,6 +3217,43 @@ export class ThreadSessionRuntime {
|
|
|
3204
3217
|
return this.submitViaOpencodeQueue(input)
|
|
3205
3218
|
}
|
|
3206
3219
|
|
|
3220
|
+
/**
|
|
3221
|
+
* Resolve parent session ID for child system prompts.
|
|
3222
|
+
* Prefer in-memory state, then SQLite, then the ingress marker.
|
|
3223
|
+
* Persist once so multi-turn child sessions keep the parent after restart.
|
|
3224
|
+
*/
|
|
3225
|
+
private async ensureParentSessionId({
|
|
3226
|
+
parentSessionId,
|
|
3227
|
+
}: {
|
|
3228
|
+
parentSessionId?: string
|
|
3229
|
+
}) {
|
|
3230
|
+
if (this.state?.parentSessionId) {
|
|
3231
|
+
return
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
const storedParentSessionId = await getThreadParentSessionId(this.threadId)
|
|
3235
|
+
if (storedParentSessionId) {
|
|
3236
|
+
threadState.setParentSessionId(this.threadId, storedParentSessionId)
|
|
3237
|
+
return
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
if (!parentSessionId) {
|
|
3241
|
+
return
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
threadState.setParentSessionId(this.threadId, parentSessionId)
|
|
3245
|
+
// Row may not exist yet on first ingress before ensureSession creates it.
|
|
3246
|
+
// Best-effort write; ensureSession path also persists after setThreadSession.
|
|
3247
|
+
await setThreadParentSessionId({
|
|
3248
|
+
threadId: this.threadId,
|
|
3249
|
+
parentSessionId,
|
|
3250
|
+
}).catch((error) => {
|
|
3251
|
+
logger.warn(
|
|
3252
|
+
`[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
3253
|
+
)
|
|
3254
|
+
})
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3207
3257
|
/**
|
|
3208
3258
|
* Serialize the preprocess callback via a lightweight promise chain, then
|
|
3209
3259
|
* route the resolved input through the normal enqueue paths.
|
|
@@ -3466,6 +3516,17 @@ export class ThreadSessionRuntime {
|
|
|
3466
3516
|
return { found: true, removed: false }
|
|
3467
3517
|
}
|
|
3468
3518
|
|
|
3519
|
+
/** Remove a queued message identified by its Discord source message ID. */
|
|
3520
|
+
removeQueuedMessage(
|
|
3521
|
+
sourceMessageId: string,
|
|
3522
|
+
): threadState.QueuedMessage | undefined {
|
|
3523
|
+
return threadState.updateQueueItemBySourceMessageId(
|
|
3524
|
+
this.threadId,
|
|
3525
|
+
sourceMessageId,
|
|
3526
|
+
() => null,
|
|
3527
|
+
)
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3469
3530
|
// ── Queue Drain ─────────────────────────────────────────────
|
|
3470
3531
|
|
|
3471
3532
|
/**
|
|
@@ -3924,6 +3985,7 @@ export class ThreadSessionRuntime {
|
|
|
3924
3985
|
agents: earlyAvailableAgents,
|
|
3925
3986
|
username: this.state?.sessionUsername || input.username,
|
|
3926
3987
|
userId: this.state?.sessionUserId || input.userId,
|
|
3988
|
+
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
3927
3989
|
}),
|
|
3928
3990
|
model: earlyModelParam,
|
|
3929
3991
|
agent: earlyAgentPreference,
|
|
@@ -4125,6 +4187,19 @@ export class ThreadSessionRuntime {
|
|
|
4125
4187
|
// Store session in DB and thread state
|
|
4126
4188
|
await setThreadSession(this.thread.id, session.id)
|
|
4127
4189
|
threadState.setSessionId(this.threadId, session.id)
|
|
4190
|
+
// Parent may have been set on ingress before the thread_sessions row
|
|
4191
|
+
// existed; write it now that the row is guaranteed.
|
|
4192
|
+
const parentSessionId = this.state?.parentSessionId
|
|
4193
|
+
if (parentSessionId) {
|
|
4194
|
+
await setThreadParentSessionId({
|
|
4195
|
+
threadId: this.thread.id,
|
|
4196
|
+
parentSessionId,
|
|
4197
|
+
}).catch((error) => {
|
|
4198
|
+
logger.warn(
|
|
4199
|
+
`[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
4200
|
+
)
|
|
4201
|
+
})
|
|
4202
|
+
}
|
|
4128
4203
|
await this.hydrateSessionEventsFromDatabase({ sessionId: session.id })
|
|
4129
4204
|
|
|
4130
4205
|
// Store session start source for scheduled tasks
|