dsh-session-guard 0.3.1 → 3.0.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/src/pause-gate.js CHANGED
@@ -46,6 +46,36 @@ export function createPluginUserMessage({ content, source }) {
46
46
  return { id, role: 'user', content, source }
47
47
  }
48
48
 
49
+ /**
50
+ * 恢复消息入队(0.1.5 compat 双路径)。
51
+ *
52
+ * 0.1.5 把 Inbox 从独立服务改为 agent-loop 只读投影(消息入队走 session 事件),
53
+ * `agent.followup` 是否保留无法静态确认,因此运行时检测:
54
+ * 1. `agent.followup(msg)` —— ≤0.1.4 与(若保留的)0.1.5 首选路径;
55
+ * 2. `agent.send(msg)` —— followup 被移除时的回退;
56
+ * 3. 两者皆无 —— 记 warn 并返回 false,绝不让 resume 因入队失败而抛错。
57
+ * @param {object} agent 目标 agent(live agent)
58
+ * @param {{id: string, role: 'user', content: unknown, source: object}} message 本地构造的 user 消息
59
+ * @param {object} [ctx] host context(仅用于 logger)
60
+ * @returns {boolean} 是否成功入队
61
+ */
62
+ export function enqueueFollowup(agent, message, ctx) {
63
+ if (typeof agent.followup === 'function') {
64
+ agent.followup(message)
65
+ return true
66
+ }
67
+ if (typeof agent.send === 'function') {
68
+ agent.send(message)
69
+ return true
70
+ }
71
+ try {
72
+ ctx?.logger?.warn?.('[session-guard] agent has no followup/send; resume message dropped')
73
+ } catch {
74
+ /* logger 不可用时静默 */
75
+ }
76
+ return false
77
+ }
78
+
49
79
  /**
50
80
  * @param {object} deps
51
81
  * @param {object} deps.ctx host context(含 agents.get / get / logger,可选 goals)
@@ -132,11 +162,31 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
132
162
  * 两种形态在 DSH 0.1.1-rc.2 与 0.1.2-rc.1 的回放日志里都保留,勿改成单读。
133
163
  */
134
164
 
165
+ /**
166
+ * 读取会话事件数组。DSH 0.1.5 移除了 `session.events` 数组访问器,改为
167
+ * `snapshotEvents()`(compatibility-guide §20.3 实测表);本专线以
168
+ * snapshotEvents() 为主,events 数组仅作回退防御,两者皆缺时返回 null
169
+ * (fail-open,与既有防御语义一致)。
170
+ */
171
+ function readSessionEvents(session) {
172
+ if (!session) return null
173
+ if (typeof session.snapshotEvents === 'function') {
174
+ try {
175
+ const events = session.snapshotEvents()
176
+ return Array.isArray(events) ? events : null
177
+ } catch {
178
+ return null
179
+ }
180
+ }
181
+ return Array.isArray(session.events) ? session.events : null
182
+ }
183
+
135
184
  /** 在 session log 查一个中断工具的实际结果(kernel 会 drain 已启动工具到 tool/result)。 */
136
185
  function findToolOutcome(agent, callId) {
137
- if (!agent?.session?.events) return null
186
+ const events = readSessionEvents(agent?.session)
187
+ if (!events) return null
138
188
  let outcome = null
139
- for (const event of agent.session.events) {
189
+ for (const event of events) {
140
190
  if (event.type === 'tool/result') {
141
191
  const message = event.data?.message ?? {}
142
192
  const block = (Array.isArray(message.content) ? message.content : []).find((b) => b?.type === 'tool-result')
@@ -162,9 +212,10 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
162
212
  }
163
213
 
164
214
  function lastUserPrompt(agent) {
165
- if (!agent?.session?.events) return null
166
- for (let index = agent.session.events.length - 1; index >= 0; index -= 1) {
167
- const event = agent.session.events[index]
215
+ const events = readSessionEvents(agent?.session)
216
+ if (!events) return null
217
+ for (let index = events.length - 1; index >= 0; index -= 1) {
218
+ const event = events[index]
168
219
  if (event.type !== 'user/message') continue
169
220
  if (event.data?.source?.kind !== 'user') continue
170
221
  return event.data.content ?? null
@@ -286,7 +337,8 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
286
337
  state.pendingPause.delete(sessionId)
287
338
  const current = currentPause(sessionId)
288
339
  if (!current.paused) return { kind: 'success', text: 'no paused task to resume' }
289
- const followup = (blocks) => agent.followup(makeFollowupMessage({ content: blocks, source: { kind: 'plugin', plugin: pluginId } }))
340
+ // 0.1.5 compat:source form: 'instructions'(0.1.5 ContextFormed 契约;旧版本忽略未知字段)
341
+ const followup = (blocks) => enqueueFollowup(agent, makeFollowupMessage({ content: blocks, source: { kind: 'plugin', plugin: pluginId, form: 'instructions' } }), ctx)
290
342
 
291
343
  if (current.forced) {
292
344
  const tool = current.interruptedTool
@@ -1,101 +1,101 @@
1
- /**
2
- * dsh-session-guard — 自研会话门暂停状态持久化(脱离 dsh-task-control)。
3
- *
4
- * 与 queueLock(src/store.js,队列锁)分离:这里是「真暂停」的持久化快照,
5
- * 仿 task-control 的状态模型,但独立子目录 /pause 避免与队列锁文件互相污染。
6
- *
7
- * 暂停/锁定状态仍**不写 session log**(harness 持久化 reader 只有已知事件集,
8
- * 自定义 `session-guard/*` 事件会导致重启后会话无法加载),走插件自有 JSON 文件:
9
- * `$DSH_HOME/.dsh/session-guard/pause/<sessionId>.json`(`DSH_SESSION_GUARD_PAUSE_DIR`
10
- * 可覆盖根目录)。原子写:tmp + rename。
11
- */
12
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
- import { dirname, join } from 'node:path'
14
- import { storeRoot } from './store.js'
15
-
16
- /** 会话 id → 安全文件名(与队列锁同规则)。 */
17
- export function encodeSessionId(id) {
18
- return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
19
- }
20
-
21
- /** 自研暂停状态根目录(独立于队列锁)。 */
22
- export function pauseStateRoot() {
23
- return (
24
- process.env.DSH_SESSION_GUARD_PAUSE_DIR ||
25
- join(storeRoot(), 'pause')
26
- )
27
- }
28
-
29
- /** 每会话暂停状态文件路径。 */
30
- export function pauseFilePath(id) {
31
- return join(pauseStateRoot(), `${encodeSessionId(id)}.json`)
32
- }
33
-
34
- /** 未暂停基线快照。 */
35
- export function idlePauseState(id) {
36
- return {
37
- sessionId: String(id),
38
- paused: false,
39
- resumeContent: null,
40
- forced: false,
41
- interruptedTool: null,
42
- deferredTools: null,
43
- updatedAt: null,
44
- }
45
- }
46
-
47
- /**
48
- * 创建暂停状态存储(内存缓存 + 磁盘持久化,原子写)。
49
- * 独立于队列锁 store,字段形态仿 task-control(paused/forced/interruptedTool/deferredTools)。
50
- */
51
- export function createPauseStore() {
52
- const cache = new Map()
53
-
54
- function read(id) {
55
- if (cache.has(id)) return cache.get(id)
56
- try {
57
- const f = pauseFilePath(id)
58
- if (!existsSync(f)) return null
59
- const v = JSON.parse(readFileSync(f, 'utf8'))
60
- cache.set(id, v)
61
- return v
62
- } catch {
63
- return null
64
- }
65
- }
66
-
67
- return {
68
- /** 读一会话暂停态;无记录返回 null(调用方 fallback idlePauseState)。 */
69
- get(id) {
70
- return read(id)
71
- },
72
- /** 原子写。 */
73
- set(id, value) {
74
- cache.set(id, value)
75
- try {
76
- const f = pauseFilePath(id)
77
- mkdirSync(dirname(f), { recursive: true })
78
- const tmp = `${f}.tmp`
79
- writeFileSync(tmp, JSON.stringify(value, null, 2))
80
- renameSync(tmp, f)
81
- } catch (e) {
82
- // 落盘失败仅影响重启恢复,内存态仍可用。
83
- console.error(`[session-guard] pause store write failed: ${String(e && e.message || e)}`)
84
- }
85
- },
86
- /** 清除(resume/cancel 后)。 */
87
- clear(id) {
88
- cache.delete(id)
89
- try {
90
- rmSync(pauseFilePath(id), { force: true })
91
- } catch {
92
- /* ignore */
93
- }
94
- },
95
- /** 当前有效快照(无记录时 idle 基线)。 */
96
- current(id) {
97
- const v = read(id)
98
- return v !== null && typeof v === 'object' ? v : idlePauseState(id)
99
- },
100
- }
101
- }
1
+ /**
2
+ * dsh-session-guard — 自研会话门暂停状态持久化(脱离 dsh-task-control)。
3
+ *
4
+ * 与 queueLock(src/store.js,队列锁)分离:这里是「真暂停」的持久化快照,
5
+ * 仿 task-control 的状态模型,但独立子目录 /pause 避免与队列锁文件互相污染。
6
+ *
7
+ * 暂停/锁定状态仍**不写 session log**(harness 持久化 reader 只有已知事件集,
8
+ * 自定义 `session-guard/*` 事件会导致重启后会话无法加载),走插件自有 JSON 文件:
9
+ * `$DSH_HOME/.dsh/session-guard/pause/<sessionId>.json`(`DSH_SESSION_GUARD_PAUSE_DIR`
10
+ * 可覆盖根目录)。原子写:tmp + rename。
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { storeRoot } from './store.js'
15
+
16
+ /** 会话 id → 安全文件名(与队列锁同规则)。 */
17
+ export function encodeSessionId(id) {
18
+ return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
19
+ }
20
+
21
+ /** 自研暂停状态根目录(独立于队列锁)。 */
22
+ export function pauseStateRoot() {
23
+ return (
24
+ process.env.DSH_SESSION_GUARD_PAUSE_DIR ||
25
+ join(storeRoot(), 'pause')
26
+ )
27
+ }
28
+
29
+ /** 每会话暂停状态文件路径。 */
30
+ export function pauseFilePath(id) {
31
+ return join(pauseStateRoot(), `${encodeSessionId(id)}.json`)
32
+ }
33
+
34
+ /** 未暂停基线快照。 */
35
+ export function idlePauseState(id) {
36
+ return {
37
+ sessionId: String(id),
38
+ paused: false,
39
+ resumeContent: null,
40
+ forced: false,
41
+ interruptedTool: null,
42
+ deferredTools: null,
43
+ updatedAt: null,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 创建暂停状态存储(内存缓存 + 磁盘持久化,原子写)。
49
+ * 独立于队列锁 store,字段形态仿 task-control(paused/forced/interruptedTool/deferredTools)。
50
+ */
51
+ export function createPauseStore() {
52
+ const cache = new Map()
53
+
54
+ function read(id) {
55
+ if (cache.has(id)) return cache.get(id)
56
+ try {
57
+ const f = pauseFilePath(id)
58
+ if (!existsSync(f)) return null
59
+ const v = JSON.parse(readFileSync(f, 'utf8'))
60
+ cache.set(id, v)
61
+ return v
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ return {
68
+ /** 读一会话暂停态;无记录返回 null(调用方 fallback idlePauseState)。 */
69
+ get(id) {
70
+ return read(id)
71
+ },
72
+ /** 原子写。 */
73
+ set(id, value) {
74
+ cache.set(id, value)
75
+ try {
76
+ const f = pauseFilePath(id)
77
+ mkdirSync(dirname(f), { recursive: true })
78
+ const tmp = `${f}.tmp`
79
+ writeFileSync(tmp, JSON.stringify(value, null, 2))
80
+ renameSync(tmp, f)
81
+ } catch (e) {
82
+ // 落盘失败仅影响重启恢复,内存态仍可用。
83
+ console.error(`[session-guard] pause store write failed: ${String(e && e.message || e)}`)
84
+ }
85
+ },
86
+ /** 清除(resume/cancel 后)。 */
87
+ clear(id) {
88
+ cache.delete(id)
89
+ try {
90
+ rmSync(pauseFilePath(id), { force: true })
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ },
95
+ /** 当前有效快照(无记录时 idle 基线)。 */
96
+ current(id) {
97
+ const v = read(id)
98
+ return v !== null && typeof v === 'object' ? v : idlePauseState(id)
99
+ },
100
+ }
101
+ }
package/src/scheduler.js CHANGED
@@ -1,42 +1,42 @@
1
- /**
2
- * dsh-session-guard — 纯状态机(可单测,零依赖)。
3
- *
4
- * 状态:NORMAL ↔ PAUSED_PEAK。
5
- * 副作用(暂停/恢复具体会话)由 host 的 tick 执行,这里只做判定与迁移检测。
6
- */
7
- import { shouldPause } from './time.js'
8
-
9
- export const STATES = {
10
- NORMAL: 'NORMAL',
11
- PAUSED_PEAK: 'PAUSED_PEAK',
12
- }
13
-
14
- /**
15
- * 计算当前状态。
16
- * @param {object} settings 同 time.shouldPause 的 settings
17
- * @param {Date|number} now
18
- * @returns {{state:string, reason:string, at:number}}
19
- */
20
- export function computeState(settings, now) {
21
- const date = now instanceof Date ? now : new Date(now)
22
- const d = shouldPause(settings, date)
23
- return {
24
- state: d.pause ? STATES.PAUSED_PEAK : STATES.NORMAL,
25
- reason: d.reason,
26
- at: date.getTime(),
27
- }
28
- }
29
-
30
- /**
31
- * 迁移检测:从 prev 到 next 是否发生了 入峰(enter) / 退峰(leave)。
32
- * @param {{state:string}|null} prev
33
- * @param {{state:string}} next
34
- * @returns {{enter:boolean, leave:boolean}}
35
- */
36
- export function transition(prev, next) {
37
- const was = prev && prev.state
38
- return {
39
- enter: next.state === STATES.PAUSED_PEAK && was !== STATES.PAUSED_PEAK,
40
- leave: was === STATES.PAUSED_PEAK && next.state !== STATES.PAUSED_PEAK,
41
- }
42
- }
1
+ /**
2
+ * dsh-session-guard — 纯状态机(可单测,零依赖)。
3
+ *
4
+ * 状态:NORMAL ↔ PAUSED_PEAK。
5
+ * 副作用(暂停/恢复具体会话)由 host 的 tick 执行,这里只做判定与迁移检测。
6
+ */
7
+ import { shouldPause } from './time.js'
8
+
9
+ export const STATES = {
10
+ NORMAL: 'NORMAL',
11
+ PAUSED_PEAK: 'PAUSED_PEAK',
12
+ }
13
+
14
+ /**
15
+ * 计算当前状态。
16
+ * @param {object} settings 同 time.shouldPause 的 settings
17
+ * @param {Date|number} now
18
+ * @returns {{state:string, reason:string, at:number}}
19
+ */
20
+ export function computeState(settings, now) {
21
+ const date = now instanceof Date ? now : new Date(now)
22
+ const d = shouldPause(settings, date)
23
+ return {
24
+ state: d.pause ? STATES.PAUSED_PEAK : STATES.NORMAL,
25
+ reason: d.reason,
26
+ at: date.getTime(),
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 迁移检测:从 prev 到 next 是否发生了 入峰(enter) / 退峰(leave)。
32
+ * @param {{state:string}|null} prev
33
+ * @param {{state:string}} next
34
+ * @returns {{enter:boolean, leave:boolean}}
35
+ */
36
+ export function transition(prev, next) {
37
+ const was = prev && prev.state
38
+ return {
39
+ enter: next.state === STATES.PAUSED_PEAK && was !== STATES.PAUSED_PEAK,
40
+ leave: was === STATES.PAUSED_PEAK && next.state !== STATES.PAUSED_PEAK,
41
+ }
42
+ }