dsh-workplan 0.0.0-stage → 1.5.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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Timeout policy for one child execution.
3
+ *
4
+ * The rule this module exists to enforce: a child is stopped only when it is
5
+ * demonstrably not making progress, or when it exceeds an absolute ceiling.
6
+ *
7
+ * History that produced this module: `runSubtask` used to race the child
8
+ * against a single 300 000 ms wall-clock timer. A research child that
9
+ * legitimately needed six minutes was recorded as `blocked`, `markDependentsBlocked`
10
+ * then turned that one verdict into a whole plan reporting 0/N complete, and the
11
+ * dashboard offered no recovery — the plan was terminal. Across the recorded
12
+ * sessions that single timer produced every one of the observed child timeouts.
13
+ *
14
+ * Progress is read from the child's own session log (`ctx.sessions.get(id).seq`),
15
+ * which grows with every appended step, tool call and streamed delta. That is a
16
+ * public, non-deprecated accessor, and it is optional: when the child session
17
+ * cannot be observed, only the absolute ceiling applies — never a shorter idle
18
+ * guess, because a false stall would kill healthy work.
19
+ */
20
+
21
+ /** Why a child execution was stopped early. */
22
+ export type TimeoutReason = 'stalled' | 'exhausted' | null
23
+
24
+ /**
25
+ * Decide whether one child execution has exhausted its budget.
26
+ *
27
+ * Pure function: the watchdog loop only supplies observations.
28
+ *
29
+ * @param input.now - current time in epoch milliseconds.
30
+ * @param input.startedAt - when the current attempt started.
31
+ * @param input.lastProgressAt - last time the child's session log grew.
32
+ * @param input.idleMs - no-progress window that means "wedged".
33
+ * @param input.hardMs - absolute ceiling for one attempt.
34
+ * @param input.progressObservable - whether the child's log can be read at all.
35
+ * @returns `'exhausted'` on the ceiling, `'stalled'` on an idle window, else `null`.
36
+ */
37
+ export function pickTimeoutReason(input: {
38
+ now: number
39
+ startedAt: number
40
+ lastProgressAt: number
41
+ idleMs: number
42
+ hardMs: number
43
+ progressObservable: boolean
44
+ }): TimeoutReason {
45
+ const { now, startedAt, lastProgressAt, idleMs, hardMs, progressObservable } = input
46
+ if (!Number.isFinite(now) || !Number.isFinite(startedAt)) return null
47
+ if (now - startedAt >= hardMs) return 'exhausted'
48
+ if (progressObservable && Number.isFinite(lastProgressAt) && now - lastProgressAt >= idleMs) {
49
+ return 'stalled'
50
+ }
51
+ return null
52
+ }
53
+
54
+ /**
55
+ * Render any rejection value as readable text.
56
+ *
57
+ * `String(error)` on a plain object yields `[object Object]`; one recorded
58
+ * subtask was blocked with exactly that string, which made the failure
59
+ * undiagnosable from the dashboard and the durable result message.
60
+ *
61
+ * @param value - a rejected value of unknown shape.
62
+ * @returns a non-empty, human-readable description.
63
+ */
64
+ export function describeError(value: unknown): string {
65
+ if (value === null || value === undefined) return '未知失败'
66
+ if (typeof value === 'string') {
67
+ const text = value.trim()
68
+ return text === '' ? '未知失败' : text
69
+ }
70
+ if (value instanceof Error) {
71
+ const message = typeof value.message === 'string' ? value.message.trim() : ''
72
+ return message !== '' ? message : (value.name || '未知失败')
73
+ }
74
+ if (typeof value === 'object') {
75
+ const record = value as Record<string, unknown>
76
+ for (const key of ['message', 'reason', 'error', 'detail']) {
77
+ const field = record[key]
78
+ if (typeof field === 'string' && field.trim() !== '') return field.trim()
79
+ if (field instanceof Error && field.message?.trim()) return field.message.trim()
80
+ if (field && typeof field === 'object') {
81
+ const nested = describeError(field)
82
+ if (nested !== '未知失败' && nested !== '[object Object]') return nested
83
+ }
84
+ }
85
+ try {
86
+ const text = JSON.stringify(value)
87
+ if (typeof text === 'string' && text !== '' && text !== '{}') return text.slice(0, 500)
88
+ } catch {
89
+ // Circular structures have no JSON form; fall through to the constructor name.
90
+ }
91
+ const name = (value as any)?.constructor?.name
92
+ return name ? `未命名错误(${name})` : '未命名对象错误'
93
+ }
94
+ return String(value)
95
+ }
96
+
97
+ /** One live progress watchdog around a single child execution. */
98
+ export interface ProgressWatchdog {
99
+ /** Rejects once with the PlanError built by `makeError`; never resolves. */
100
+ readonly fired: Promise<never>
101
+ /** Stop polling. Safe to call more than once. */
102
+ stop(): void
103
+ /** Last time the observed session log grew (or the attempt start). */
104
+ lastProgressAt(): number
105
+ /** How many times the observed session log grew. */
106
+ progressEvents(): number
107
+ }
108
+
109
+ /**
110
+ * Watch one child's session-log growth and reject when it stalls or runs out.
111
+ *
112
+ * @param input.getSeq - reads the child's current event sequence, or undefined when unobservable.
113
+ * @param input.idleMs - no-progress window.
114
+ * @param input.hardMs - absolute ceiling.
115
+ * @param input.pollMs - observation period.
116
+ * @param input.makeError - builds the rejection for the decided reason.
117
+ * @param input.onProgress - called after each observed log growth.
118
+ * @param input.now - clock, injectable for tests.
119
+ * @returns a watchdog handle.
120
+ */
121
+ export function createProgressWatchdog(input: {
122
+ getSeq: () => number | undefined
123
+ idleMs: number
124
+ hardMs: number
125
+ makeError: (reason: Exclude<TimeoutReason, null>) => Error
126
+ pollMs?: number
127
+ onProgress?: (info: { now: number; seq: number; elapsedMs: number }) => void
128
+ now?: () => number
129
+ }): ProgressWatchdog {
130
+ const clock = input.now ?? (() => Date.now())
131
+ const pollMs = Math.max(10, Math.floor(input.pollMs ?? 2000))
132
+ const startedAt = clock()
133
+ let lastProgress = startedAt
134
+ let lastSeq: number | null = null
135
+ let growth = 0
136
+ let settled = false
137
+ let timer: ReturnType<typeof setInterval> | null = null
138
+ let rejectOnce: (error: Error) => void = () => {}
139
+
140
+ const fired = new Promise<never>((_resolve, reject) => {
141
+ rejectOnce = reject
142
+ })
143
+ // The caller may await a race in which this promise loses; keep the
144
+ // rejection handled so Node never reports an unhandled rejection.
145
+ fired.catch(() => {})
146
+
147
+ const finish = (reason: Exclude<TimeoutReason, null>) => {
148
+ if (settled) return
149
+ settled = true
150
+ if (timer) {
151
+ clearInterval(timer)
152
+ timer = null
153
+ }
154
+ rejectOnce(input.makeError(reason))
155
+ }
156
+
157
+ const poll = () => {
158
+ if (settled) return
159
+ const now = clock()
160
+ let seq: number | undefined
161
+ try {
162
+ seq = input.getSeq()
163
+ } catch {
164
+ seq = undefined
165
+ }
166
+ if (typeof seq === 'number' && Number.isFinite(seq)) {
167
+ if (lastSeq === null) {
168
+ lastSeq = seq
169
+ } else if (seq > lastSeq) {
170
+ lastSeq = seq
171
+ growth += 1
172
+ lastProgress = now
173
+ try {
174
+ input.onProgress?.({ now, seq, elapsedMs: now - startedAt })
175
+ } catch {
176
+ // An observer must never break the watchdog.
177
+ }
178
+ }
179
+ }
180
+ const reason = pickTimeoutReason({
181
+ now,
182
+ startedAt,
183
+ lastProgressAt: lastProgress,
184
+ idleMs: Math.max(1, Math.floor(input.idleMs)),
185
+ hardMs: Math.max(1, Math.floor(input.hardMs)),
186
+ progressObservable: lastSeq !== null,
187
+ })
188
+ if (reason) finish(reason)
189
+ }
190
+
191
+ timer = setInterval(poll, pollMs)
192
+ timer.unref?.()
193
+
194
+ return {
195
+ fired,
196
+ stop() {
197
+ if (settled) return
198
+ settled = true
199
+ if (timer) {
200
+ clearInterval(timer)
201
+ timer = null
202
+ }
203
+ },
204
+ lastProgressAt: () => lastProgress,
205
+ progressEvents: () => growth,
206
+ }
207
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": [
7
+ "ES2022",
8
+ "DOM",
9
+ "DOM.Iterable"
10
+ ],
11
+ "types": [
12
+ "node"
13
+ ],
14
+ "rootDir": "src",
15
+ "outDir": "dist",
16
+ "strict": true,
17
+ "noImplicitAny": true,
18
+ "noUnusedLocals": false,
19
+ "noUnusedParameters": false,
20
+ "skipLibCheck": true,
21
+ "esModuleInterop": true,
22
+ "forceConsistentCasingInFileNames": true,
23
+ "sourceMap": false,
24
+ "declaration": false,
25
+ "moduleDetection": "legacy"
26
+ },
27
+ "include": [
28
+ "src/**/*.ts"
29
+ ],
30
+ "exclude": [
31
+ "node_modules",
32
+ "dist",
33
+ "lib",
34
+ "tests"
35
+ ]
36
+ }