@modesto_ma/dsh-channels 0.2.0 → 0.3.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/README.md +53 -29
- package/docs/access-control-design.md +1 -1
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +15 -13
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +23 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +1110 -603
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +27 -8
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +119 -41
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +18 -14
- package/src/settings/surface.ts +12 -4
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
package/src/config.ts
CHANGED
|
@@ -103,6 +103,23 @@ export const configSchema = z.object({
|
|
|
103
103
|
* `strict` fails the reply instead.
|
|
104
104
|
*/
|
|
105
105
|
threadFallback: z.enum(['degrade', 'strict']).default('degrade'),
|
|
106
|
+
/**
|
|
107
|
+
* Per-topic working-directory isolation inside one user workspace.
|
|
108
|
+
*
|
|
109
|
+
* - `auto` (default): use a Git worktree per topic when the user workspace
|
|
110
|
+
* is a Git repository (the workspace directory contains `.git`), otherwise
|
|
111
|
+
* a plain subdirectory.
|
|
112
|
+
* - `subdir`: always a plain subdirectory (no Git requirement, no duplicated
|
|
113
|
+
* checkouts).
|
|
114
|
+
* - `worktree`: always try a Git worktree; when the workspace is not a Git
|
|
115
|
+
* repository, the `worktrees` service is unavailable, or creation is
|
|
116
|
+
* denied, log a warning and fall back to a subdirectory.
|
|
117
|
+
*
|
|
118
|
+
* The workspace itself stays the Session header cwd either way, so Sessions
|
|
119
|
+
* remain grouped under the user's project; only the effective working
|
|
120
|
+
* directory changes. See `docs/workspace-isolation-design.md`.
|
|
121
|
+
*/
|
|
122
|
+
topicIsolation: z.enum(['auto', 'subdir', 'worktree']).default('auto'),
|
|
106
123
|
/**
|
|
107
124
|
* Route-table GC threshold. Route records (and stale in-flight repair
|
|
108
125
|
* snapshots) whose last activity is older than this many milliseconds are
|
|
@@ -129,17 +146,19 @@ export const configSchema = z.object({
|
|
|
129
146
|
*/
|
|
130
147
|
acceptAttachments: z.boolean().default(true),
|
|
131
148
|
/**
|
|
132
|
-
* Delete the
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
149
|
+
* Delete the topic directories of stale conversations, plus legacy
|
|
150
|
+
* one-workspace-per-topic directories written by older plugin versions.
|
|
151
|
+
* `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
|
|
152
|
+
* directories when their last activity is older than the threshold. User
|
|
153
|
+
* workspaces themselves are never deleted. The managed Web panel exposes
|
|
154
|
+
* this as a select, previews the candidates, and can run one cleanup
|
|
155
|
+
* manually. DSH Session logs are never deleted.
|
|
137
156
|
*/
|
|
138
157
|
workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default('off'),
|
|
139
158
|
/**
|
|
140
|
-
* Run the
|
|
141
|
-
*
|
|
142
|
-
*
|
|
159
|
+
* Run the retention cleanup at startup and once per day. Off by default:
|
|
160
|
+
* deletion is irreversible, so it stays an explicit opt-in even when a
|
|
161
|
+
* retention threshold is configured.
|
|
143
162
|
*/
|
|
144
163
|
workspaceRetentionAuto: z.boolean().default(false),
|
|
145
164
|
/**
|
package/src/domain.ts
CHANGED
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from 'zod'
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped
|
|
15
|
+
* fallback.
|
|
16
|
+
*
|
|
17
|
+
* Version 2 adds the optional `sessionDir` / `worktreeBranch` fields (the
|
|
18
|
+
* Session's effective working directory). Version-1 records stay readable: an
|
|
19
|
+
* absent `sessionDir` means the workspace path itself is the working directory
|
|
20
|
+
* (the pre-v2 model).
|
|
21
|
+
*/
|
|
22
|
+
export const ROUTE_RECORD_VERSION = 2
|
|
15
23
|
|
|
16
24
|
export const sessionHistoryEntrySchema = z.object({
|
|
17
25
|
/** One Session that has been bound to this route. */
|
|
@@ -35,8 +43,16 @@ export const routeRecordSchema = z.object({
|
|
|
35
43
|
chatType: z.enum(['p2p', 'group']),
|
|
36
44
|
/** Workspace owner (sender open_id). */
|
|
37
45
|
senderOpenId: z.string().min(1),
|
|
38
|
-
/** Workspace
|
|
46
|
+
/** Workspace root owning this route: the Session header cwd / sandbox root. */
|
|
39
47
|
workspacePath: z.string().min(1),
|
|
48
|
+
/**
|
|
49
|
+
* Effective working directory of the bound Session (the per-topic directory
|
|
50
|
+
* or Git worktree checkout). Absent on records written before per-topic
|
|
51
|
+
* directories existed: `workspacePath` is then the working directory too.
|
|
52
|
+
*/
|
|
53
|
+
sessionDir: z.string().min(1).optional(),
|
|
54
|
+
/** Git branch owning `sessionDir` when it is a worktree checkout. */
|
|
55
|
+
worktreeBranch: z.string().min(1).optional(),
|
|
40
56
|
/** Message id of the latest streaming card (updated per turn). */
|
|
41
57
|
cardMessageId: z.string().min(1),
|
|
42
58
|
/** Epoch ms when the record was created. */
|
|
@@ -71,6 +87,12 @@ export const expiredRouteRecordSchema = z.object({
|
|
|
71
87
|
/** Workspace the retired route belonged to; absent in tombstones written by
|
|
72
88
|
* older plugin versions, so the field must stay optional. */
|
|
73
89
|
workspacePath: z.string().min(1).optional(),
|
|
90
|
+
/** Effective working directory the retired route's Session used; absent in
|
|
91
|
+
* legacy tombstones (retention cleanup then treats `workspacePath` as the
|
|
92
|
+
* directory, matching pre-v2 routes). */
|
|
93
|
+
sessionDir: z.string().min(1).optional(),
|
|
94
|
+
/** Git branch of that directory when it was a worktree checkout. */
|
|
95
|
+
worktreeBranch: z.string().min(1).optional(),
|
|
74
96
|
/** Epoch ms of the last activity through the route before retirement.
|
|
75
97
|
* Optional for the same legacy-compatibility reason; consumers fall back to
|
|
76
98
|
* `expiredAt` when it is absent. */
|
|
@@ -116,6 +138,15 @@ export function appendSessionHistory(
|
|
|
116
138
|
})
|
|
117
139
|
}
|
|
118
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Effective working directory of one route's Session. Records written before
|
|
143
|
+
* per-topic directories have no `sessionDir`; their workspace path is the
|
|
144
|
+
* directory the Session really works in.
|
|
145
|
+
*/
|
|
146
|
+
export function sessionDirOf(record: Pick<RouteRecord, 'workspacePath' | 'sessionDir'>): string {
|
|
147
|
+
return record.sessionDir ?? record.workspacePath
|
|
148
|
+
}
|
|
149
|
+
|
|
119
150
|
/**
|
|
120
151
|
* In-flight turn snapshot: everything needed to repair a streaming card that a
|
|
121
152
|
* restart left mid-flight (stuck panel, leftover Typing reaction, never-patched
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
* Agent-scoped system-prompt guidance for Feishu attachments.
|
|
3
3
|
*
|
|
4
4
|
* The channel archives inbound images/files/forwarded records below
|
|
5
|
-
* `<
|
|
6
|
-
* makes that convention discoverable: it tells the Agent where
|
|
7
|
-
* live, how to read them, and that reading is opt-in per user
|
|
5
|
+
* `<session working directory>/attachments/` without any model turn. This
|
|
6
|
+
* section is what makes that convention discoverable: it tells the Agent where
|
|
7
|
+
* attachments live, how to read them, and that reading is opt-in per user
|
|
8
|
+
* request.
|
|
8
9
|
*
|
|
9
10
|
* Registered inside the Agent scope (through `inject(['systemPrompt'])`), so
|
|
10
11
|
* it is disposed with the Agent and does not leak into other compositions.
|
|
12
|
+
* It is mounted only after the Session's effective working directory is known
|
|
13
|
+
* (see `SessionManager.mountGuidance`), so the absolute path is accurate even
|
|
14
|
+
* when that directory is a Git worktree created after Agent creation.
|
|
11
15
|
*
|
|
12
16
|
* @module dsh-channels/feishu/agent-context
|
|
13
17
|
*/
|
|
@@ -26,13 +30,13 @@ const FEISHU_ATTACHMENTS_ORDER = 950
|
|
|
26
30
|
|
|
27
31
|
/** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
|
|
28
32
|
export const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
|
|
29
|
-
Every Feishu conversation (topic) has its own workspace and its own attachments directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
|
|
33
|
+
Every Feishu conversation (topic) has its own working directory inside its user's workspace, and every working directory has its own \`attachments/\` directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
|
|
30
34
|
|
|
31
35
|
Attachments are never inserted into the conversation automatically. Read them only when the user's request needs them, and do not read attachments the user did not mention:
|
|
32
36
|
- images: use \`read_image\` with the file path;
|
|
33
37
|
- text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
|
|
34
38
|
- other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
|
|
35
|
-
If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations
|
|
39
|
+
If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.`
|
|
36
40
|
|
|
37
41
|
/**
|
|
38
42
|
* Full guidance for one Session. The absolute per-session directory is
|
package/src/index.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 一个 dsh 实例对应一个飞书机器人;用户通过私聊、群聊 @、话题群 @ 三种方式
|
|
5
5
|
* 发起会话。每条触发消息:立即加 “Typing” 表情 → 以话题形式回复(创建话题)
|
|
6
|
-
* → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 +
|
|
7
|
-
*
|
|
6
|
+
* → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 临时活动行 +
|
|
7
|
+
* 富文本正文 + 结算页脚,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
|
|
8
8
|
* “DONE”,失败显示结构化错误并更新为 “No”。
|
|
9
9
|
*
|
|
10
10
|
* 工作区归属:私聊一人一工作区;群聊中每个用户在该群拥有独立工作区
|
|
11
|
-
*
|
|
11
|
+
* (工作区是会话 header cwd,保证 GUI 归属与沙箱边界)。每个话题在该工作区
|
|
12
|
+
* 内拥有独立的工作目录(`topics/<话题>`,Git 仓库工作区可自动改用 worktree),
|
|
13
|
+
* 因此话题之间的文件互不干扰,而工作区数量只随用户增长。
|
|
12
14
|
*
|
|
13
15
|
* @module dsh-channels
|
|
14
16
|
*/
|
|
@@ -51,7 +53,7 @@ export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './sett
|
|
|
51
53
|
export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
|
|
52
54
|
export type { HostWebServer } from './settings/routes.ts'
|
|
53
55
|
export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
|
|
54
|
-
export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES,
|
|
56
|
+
export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
|
|
55
57
|
export { ApprovalAnswerer } from './approvals.ts'
|
|
56
58
|
export { QuestionAnswerer } from './questions.ts'
|
|
57
59
|
export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
|
|
@@ -61,8 +63,8 @@ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT
|
|
|
61
63
|
export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
|
|
62
64
|
export { FeishuService } from './service.ts'
|
|
63
65
|
export { TurnStreamer } from './streamer.ts'
|
|
64
|
-
export { ensureWorkspace, stagingPathFor, topicTokenFor, workspacePathFor, workspaceTitleFor } from './workspaces.ts'
|
|
65
|
-
export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
|
|
66
|
+
export { ensureWorkspace, legacyTopicWorkspaceRoot, stagingPathFor, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
|
|
67
|
+
export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
|
|
66
68
|
|
|
67
69
|
/** Cordis function-plugin name. */
|
|
68
70
|
export const name = 'channels'
|
package/src/routing/inbound.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { AgentHandle, HostContext, SessionId, UserMessage, Workspace } from
|
|
|
14
14
|
import type { FeishuConfig } from '../config.ts'
|
|
15
15
|
import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
|
|
16
16
|
import { RouteOperationQueue } from '../commands.ts'
|
|
17
|
-
import { chatRouteKey } from '../domain.ts'
|
|
17
|
+
import { chatRouteKey, sessionDirOf } from '../domain.ts'
|
|
18
18
|
import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
|
|
19
19
|
import type { CardDelivery } from '../feishu/delivery.ts'
|
|
20
20
|
import {
|
|
@@ -28,6 +28,10 @@ import type { InflightRepair } from '../session/repair.ts'
|
|
|
28
28
|
import type { SessionManager } from '../session/manager.ts'
|
|
29
29
|
import type { RouteStore } from '../session/routes.ts'
|
|
30
30
|
import type { TurnStreamer } from '../streamer.ts'
|
|
31
|
+
import {
|
|
32
|
+
enterTopic, placementServices, planTopic, restoreTopicDir,
|
|
33
|
+
} from '../session/isolation.ts'
|
|
34
|
+
import type { PlacementServices, TopicPlacement, TopicPlan } from '../session/isolation.ts'
|
|
31
35
|
import { ensureWorkspace, stagingPathFor, topicTokenFor } from '../workspaces.ts'
|
|
32
36
|
|
|
33
37
|
/** The command plane the inbound router delegates admission-time work to. */
|
|
@@ -313,12 +317,12 @@ export class InboundRouter {
|
|
|
313
317
|
|
|
314
318
|
return await this.routeOperations.run(record?.key ?? chatKey, async () => {
|
|
315
319
|
if (!this.started) return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
|
|
316
|
-
// An existing topic has
|
|
317
|
-
// stages files below baseDir until its first
|
|
318
|
-
//
|
|
320
|
+
// An existing topic already has its own working directory; a
|
|
321
|
+
// conversation without one stages files below baseDir until its first
|
|
322
|
+
// Session creates the topic directory and adopts them.
|
|
319
323
|
const directory = record === undefined
|
|
320
324
|
? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
|
|
321
|
-
: attachmentsDir(record
|
|
325
|
+
: attachmentsDir(sessionDirOf(record))
|
|
322
326
|
return await this.attachments.archive(msg, directory)
|
|
323
327
|
})
|
|
324
328
|
}
|
|
@@ -395,25 +399,20 @@ export class InboundRouter {
|
|
|
395
399
|
const initialStats = { sessionId, inputTokens: 0, outputTokens: 0 }
|
|
396
400
|
|
|
397
401
|
// 2. First card frame — also creates the topic (reply_in_thread). The
|
|
398
|
-
// topic identity is only known after Feishu answers, and it
|
|
399
|
-
//
|
|
402
|
+
// topic identity is only known after Feishu answers, and it names the
|
|
403
|
+
// topic's private working directory inside the user's workspace.
|
|
400
404
|
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(sessionId))
|
|
401
405
|
const typingId = await typingPromise
|
|
402
406
|
const key = sent.degraded || sent.threadId === undefined ? chatKey : sent.threadId
|
|
403
407
|
const kind = sent.degraded || sent.threadId === undefined ? 'chat' : 'thread'
|
|
404
408
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
await adoptStagedAttachments(
|
|
413
|
-
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
414
|
-
workspace.path,
|
|
415
|
-
)
|
|
416
|
-
} catch (error) {
|
|
409
|
+
const topicToken = topicTokenFor(key, kind, sessionId)
|
|
410
|
+
const services: PlacementServices = placementServices(this.ctx)
|
|
411
|
+
const warn = (message: string, ...args: unknown[]): void => {
|
|
412
|
+
this.ctx.logger?.warn?.(message, ...args)
|
|
413
|
+
}
|
|
414
|
+
/** Patch the streaming card to its terminal creation-failure state. */
|
|
415
|
+
const failCreation = async (error: unknown): Promise<void> => {
|
|
417
416
|
void this.repair.clear(sent.messageId)
|
|
418
417
|
await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
|
|
419
418
|
phase: 'done',
|
|
@@ -421,6 +420,20 @@ export class InboundRouter {
|
|
|
421
420
|
body: `会话创建失败:${String(error)}`,
|
|
422
421
|
stats: { ...initialStats },
|
|
423
422
|
})).catch(() => {})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 3. User workspace (the durable project this Session is grouped under)
|
|
426
|
+
// plus the per-topic placement plan. No Agent exists yet; the plan
|
|
427
|
+
// materializes anything that must exist before creation.
|
|
428
|
+
let workspace: Workspace
|
|
429
|
+
let plan: TopicPlan
|
|
430
|
+
try {
|
|
431
|
+
workspace = await ensureWorkspace(
|
|
432
|
+
this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId,
|
|
433
|
+
)
|
|
434
|
+
plan = await planTopic(services, this.config, workspace.path, topicToken, warn)
|
|
435
|
+
} catch (error) {
|
|
436
|
+
await failCreation(error)
|
|
424
437
|
throw error
|
|
425
438
|
}
|
|
426
439
|
|
|
@@ -439,27 +452,39 @@ export class InboundRouter {
|
|
|
439
452
|
attempts: 0,
|
|
440
453
|
})
|
|
441
454
|
|
|
442
|
-
// 4. Create the
|
|
443
|
-
|
|
455
|
+
// 4. Create the Session (header cwd = user workspace when directories can
|
|
456
|
+
// be switched), then enter its own topic directory / worktree.
|
|
457
|
+
let handle: AgentHandle | undefined
|
|
458
|
+
let placement: TopicPlacement | undefined
|
|
444
459
|
try {
|
|
445
|
-
handle = await this.sessions.createSession(sessionId,
|
|
460
|
+
handle = await this.sessions.createSession(sessionId, plan.cwd)
|
|
461
|
+
placement = await enterTopic(services, handle.agent, plan, workspace.path, topicToken, warn)
|
|
462
|
+
// A workspace attachment only validates when the header cwd is the
|
|
463
|
+
// workspace itself; the hard fallback (no workingDirectory service) must
|
|
464
|
+
// skip it instead of logging a guaranteed failure.
|
|
465
|
+
if (plan.cwd === workspace.path) {
|
|
466
|
+
void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
|
|
467
|
+
warn('dsh-channels: attaching initial session %s to workspace failed: %s', sessionId, String(error))
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
// Files staged before this topic existed belong to this conversation.
|
|
471
|
+
await adoptStagedAttachments(
|
|
472
|
+
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
473
|
+
placement.sessionDir,
|
|
474
|
+
).catch((error: unknown) => {
|
|
475
|
+
warn('dsh-channels: adopting staged attachments failed: %s', String(error))
|
|
476
|
+
})
|
|
477
|
+
await this.sessions.mountGuidance(handle.agent, placement.sessionDir)
|
|
446
478
|
} catch (error) {
|
|
447
|
-
// The card lands in a final failure state — no repair needed later.
|
|
448
|
-
|
|
449
|
-
await
|
|
450
|
-
|
|
451
|
-
reasoning: '',
|
|
452
|
-
body: `会话创建失败:${String(error)}`,
|
|
453
|
-
stats: { ...initialStats },
|
|
454
|
-
})).catch(() => {})
|
|
479
|
+
// The card lands in a final failure state — no repair needed later. A
|
|
480
|
+
// Session created just before the failure must not stay live unnoticed.
|
|
481
|
+
await handle?.dispose().catch(() => {})
|
|
482
|
+
await failCreation(error)
|
|
455
483
|
throw error
|
|
456
484
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
sessionId, String(error),
|
|
461
|
-
)
|
|
462
|
-
})
|
|
485
|
+
if (handle === undefined || placement === undefined) {
|
|
486
|
+
throw new Error('dsh-channels: session creation completed without a handle')
|
|
487
|
+
}
|
|
463
488
|
|
|
464
489
|
// 5. Persist the route (thread-scoped, or degraded chat-scoped).
|
|
465
490
|
const createdAt = handle.agent.session.header.createdAt
|
|
@@ -471,6 +496,8 @@ export class InboundRouter {
|
|
|
471
496
|
chatType: msg.chatType,
|
|
472
497
|
senderOpenId: msg.senderId,
|
|
473
498
|
workspacePath: workspace.path,
|
|
499
|
+
sessionDir: placement.sessionDir,
|
|
500
|
+
...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
|
|
474
501
|
cardMessageId: sent.messageId,
|
|
475
502
|
createdAt,
|
|
476
503
|
lastActivityAt: createdAt,
|
|
@@ -487,24 +514,42 @@ export class InboundRouter {
|
|
|
487
514
|
// Defence in depth: every current caller checks this first, but a future
|
|
488
515
|
// call path must not be able to continue another user's Session.
|
|
489
516
|
if (!this.access.ownsRoute(msg, record)) return
|
|
517
|
+
const services = placementServices(this.ctx)
|
|
518
|
+
const warn = (message: string, ...args: unknown[]): void => {
|
|
519
|
+
this.ctx.logger?.warn?.(message, ...args)
|
|
520
|
+
}
|
|
521
|
+
const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
|
|
522
|
+
const live = await this.sessions.ensureLive(record)
|
|
523
|
+
// The topic directory can disappear independently of the Session (manual
|
|
524
|
+
// deletion, external cleanup). Recreate it and re-align the effective
|
|
525
|
+
// working directory so the turn never leaks into the shared workspace root.
|
|
526
|
+
const placement = await restoreTopicDir(services, live.agent, {
|
|
527
|
+
workspacePath: record.workspacePath,
|
|
528
|
+
sessionDir: sessionDirOf(record),
|
|
529
|
+
...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
|
|
530
|
+
}, warn)
|
|
531
|
+
let current: RouteRecord = record
|
|
532
|
+
if (placement.sessionDir !== sessionDirOf(record) || placement.worktreeBranch !== record.worktreeBranch) {
|
|
533
|
+
current = { ...record, sessionDir: placement.sessionDir }
|
|
534
|
+
delete current.worktreeBranch
|
|
535
|
+
if (placement.worktreeBranch !== undefined) current = { ...current, worktreeBranch: placement.worktreeBranch }
|
|
536
|
+
}
|
|
490
537
|
// Files staged before this conversation's topic existed follow its next
|
|
491
538
|
// turn (for example a file sent in the chat before the first prompt).
|
|
492
539
|
await adoptStagedAttachments(
|
|
493
540
|
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
494
|
-
|
|
541
|
+
placement.sessionDir,
|
|
495
542
|
).catch((error: unknown) => {
|
|
496
543
|
this.ctx.logger?.warn?.('dsh-channels: adopting staged attachments failed: %s', String(error))
|
|
497
544
|
})
|
|
498
|
-
const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
|
|
499
|
-
const live = await this.sessions.ensureLive(record)
|
|
500
545
|
|
|
501
|
-
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(
|
|
546
|
+
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId))
|
|
502
547
|
const typingId = await typingPromise
|
|
503
548
|
|
|
504
549
|
// Repair snapshot first — same crash-window reasoning as `startSession`.
|
|
505
550
|
await this.repair.write({
|
|
506
551
|
key: sent.messageId,
|
|
507
|
-
sessionId:
|
|
552
|
+
sessionId: current.sessionId,
|
|
508
553
|
chatId: msg.chatId,
|
|
509
554
|
triggerMessageId: msg.messageId,
|
|
510
555
|
reactionId: typingId,
|
|
@@ -513,8 +558,8 @@ export class InboundRouter {
|
|
|
513
558
|
attempts: 0,
|
|
514
559
|
})
|
|
515
560
|
|
|
516
|
-
await this.routes.put(
|
|
517
|
-
...
|
|
561
|
+
await this.routes.put(current.key, {
|
|
562
|
+
...current,
|
|
518
563
|
cardMessageId: sent.messageId,
|
|
519
564
|
lastActivityAt: Date.now(),
|
|
520
565
|
})
|