@danceiny/gotry 0.2.0-rc.29 → 0.2.0-rc.31
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -83
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-inner.js +9 -2
- package/client/client.js +183 -0
- package/cordis.gotry-patch.yml +2 -0
- package/dist/capabilities/artifacts.js +41 -4
- package/dist/capabilities/doctor.js +7 -15
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +19 -4
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/flyai-setup-tests.js +75 -2
- package/dist/scripts/gotry-web-api-tests.js +114 -0
- package/dist/scripts/gotry-web-client-tests.js +28 -0
- package/dist/scripts/issue-271-liveness-tests.js +9 -1
- package/dist/scripts/turn-handoff-collect-tests.js +7 -1
- package/dist/scripts/turn-handoff-collect.js +68 -20
- package/dist/scripts/turn-handoff-job-tests.js +222 -0
- package/dist/scripts/turn-handoff-web-e2e.js +240 -0
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +157 -0
- package/dist/src/index.js +30 -8
- package/dist/src/turn-deadline.js +113 -16
- package/dist/src/turn-handoff-job.js +124 -0
- package/extension/manifest.json +2 -2
- package/package.json +8 -2
- package/ts/capabilities/artifacts.ts +40 -5
- package/ts/scripts/turn-handoff-collect.ts +63 -21
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +140 -0
- package/ts/src/index.ts +30 -8
- package/ts/src/turn-deadline.ts +111 -18
- package/ts/src/turn-handoff-job.ts +120 -0
package/ts/src/turn-deadline.ts
CHANGED
|
@@ -13,19 +13,19 @@
|
|
|
13
13
|
* `gotry_turn_handoff.v1` 工单并指令模型告知用户 ETA 与回访方式。
|
|
14
14
|
* - handoff 工单是**新的独立格式**,落在 `<stateRoot>/gotry-state/
|
|
15
15
|
* turn-handoffs/`,不与 `async/` 的 loop 求解工单混用——async-collect
|
|
16
|
-
* 对无 spec 的 state 会立即结算 failed(假失败)
|
|
17
|
-
*
|
|
16
|
+
* 对无 spec 的 state 会立即结算 failed(假失败)。产品通过原生 jobs
|
|
17
|
+
* 启动独立收集器;仅落单不代表后台已开始。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { randomUUID } from 'node:crypto'
|
|
21
21
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
22
|
-
import { join } from 'node:path'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
23
|
import type { Context } from '@deepseek-ai/cordis'
|
|
24
24
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
25
25
|
// 类型面显式依赖:session/event、session/disposed 的事件表声明在 dsh-session 的
|
|
26
26
|
// cordis Events augmentation 里——不显式导入时,该声明只在 peer 恰好物化进
|
|
27
27
|
// ts/node_modules 的机器上可见(legacy-peer-deps 安装则缺,5 个 TS2345/TS7006)。
|
|
28
|
-
import type {} from '@deepseek-ai/dsh-session'
|
|
28
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
29
29
|
import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
|
30
30
|
import { classifyTurn, TURN_HANDOFF_ETA_LABEL, turnPolicyFor, type TurnPolicy } from './turn-policy.ts'
|
|
31
31
|
|
|
@@ -41,7 +41,11 @@ export interface TurnHandoffTicket {
|
|
|
41
41
|
userMessage: string
|
|
42
42
|
requestedAt: string
|
|
43
43
|
etaLabel: string
|
|
44
|
-
status: 'open' | 'settled' | 'failed'
|
|
44
|
+
status: 'open' | 'running' | 'settled' | 'failed'
|
|
45
|
+
jobId?: string
|
|
46
|
+
workspaceCwd?: string
|
|
47
|
+
ownerSessionId?: string
|
|
48
|
+
context?: string
|
|
45
49
|
/** 结算时间(ISO);open 时缺省。 */
|
|
46
50
|
settledAt?: string
|
|
47
51
|
/** 结算交付物文件名(与工单同目录的 .deliverable.md)。 */
|
|
@@ -57,6 +61,8 @@ export interface TurnHandoffTicketView {
|
|
|
57
61
|
objective: string
|
|
58
62
|
requestedAt: string
|
|
59
63
|
etaLabel: string
|
|
64
|
+
jobId?: string
|
|
65
|
+
deliverablePath?: string
|
|
60
66
|
settledAt?: string
|
|
61
67
|
error?: string
|
|
62
68
|
deliverableExcerpt?: string
|
|
@@ -71,9 +77,37 @@ export interface TurnDeadlineOptions {
|
|
|
71
77
|
stateRoot?: string
|
|
72
78
|
/** Wall-clock source — overridden by tests for determinism. */
|
|
73
79
|
now?: () => number
|
|
80
|
+
/** Product host owns dispatch and native job tracking. Absent means queued. */
|
|
81
|
+
startHandoff?: (ticket: TurnHandoffTicket, agent: Agent, stateRoot: string) => Promise<TurnHandoffTicket>
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
function handoffContext(events: readonly SessionEvent[]): string | undefined {
|
|
85
|
+
const questionIds = new Set(events.filter(event => event.type === 'tool/call' && event.data.name === 'ask_user_question')
|
|
86
|
+
.map(event => event.type === 'tool/call' ? event.data.callId : ''))
|
|
87
|
+
// Human constraints are lossless. Refuse dispatch if they exceed the input
|
|
88
|
+
// budget; silently cutting an accessibility/budget answer changes the task.
|
|
89
|
+
const answers = events.filter(event => (event.type === 'user/message' && event.data.source.kind === 'user')
|
|
90
|
+
|| (event.type === 'tool/call' && questionIds.has(event.data.callId))
|
|
91
|
+
|| (event.type === 'tool/result' && event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId)))
|
|
92
|
+
.map(event => JSON.stringify(event.data)).join('\n')
|
|
93
|
+
if (answers.length > 64_000) throw new Error('Prior user answers exceed the handoff context limit; planning paused without dropping constraints. Start a new session with the complete trip constraints.')
|
|
94
|
+
let remaining = 18_000
|
|
95
|
+
const evidence: string[] = []
|
|
96
|
+
for (const event of [...events].reverse()) {
|
|
97
|
+
if (event.type !== 'tool/result' || (event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId))) continue
|
|
98
|
+
const row = JSON.stringify(event.data)
|
|
99
|
+
// Evidence may be excerpted, with an explicit marker and valid JSON.
|
|
100
|
+
const excerpt = row.length > remaining
|
|
101
|
+
? JSON.stringify({ truncated: true, excerpt: row.slice(-Math.floor(remaining / 2)) }) : row
|
|
102
|
+
if (excerpt.length + 1 > remaining) break
|
|
103
|
+
evidence.unshift(excerpt)
|
|
104
|
+
remaining -= excerpt.length + 1
|
|
105
|
+
if (remaining < 100) break
|
|
106
|
+
}
|
|
107
|
+
return answers || evidence.length ? `${answers}\n${evidence.join('\n')}` : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function writeTurnHandoffTicket(stateRoot: string, userMessage: string, details: Pick<TurnHandoffTicket, 'workspaceCwd' | 'ownerSessionId' | 'context'> = {}): Promise<TurnHandoffTicket> {
|
|
77
111
|
const ticket: TurnHandoffTicket = {
|
|
78
112
|
schema: TURN_HANDOFF_SCHEMA,
|
|
79
113
|
id: `th-${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`,
|
|
@@ -82,6 +116,7 @@ export async function writeTurnHandoffTicket(stateRoot: string, userMessage: str
|
|
|
82
116
|
requestedAt: new Date().toISOString(),
|
|
83
117
|
etaLabel: TURN_HANDOFF_ETA_LABEL,
|
|
84
118
|
status: 'open',
|
|
119
|
+
...details,
|
|
85
120
|
}
|
|
86
121
|
await writeTicketJson(stateRoot, ticket)
|
|
87
122
|
return ticket
|
|
@@ -92,7 +127,7 @@ function handoffDir(stateRoot: string): string {
|
|
|
92
127
|
return join(root, 'gotry-state', 'turn-handoffs')
|
|
93
128
|
}
|
|
94
129
|
|
|
95
|
-
async function writeTicketJson(stateRoot: string, ticket: TurnHandoffTicket): Promise<string> {
|
|
130
|
+
export async function writeTicketJson(stateRoot: string, ticket: TurnHandoffTicket): Promise<string> {
|
|
96
131
|
const dir = handoffDir(stateRoot)
|
|
97
132
|
await mkdir(dir, { recursive: true })
|
|
98
133
|
const path = join(dir, `${ticket.id}.json`)
|
|
@@ -151,12 +186,14 @@ export async function listTurnHandoffTickets(stateRoot: string): Promise<TurnHan
|
|
|
151
186
|
objective: raw.objective,
|
|
152
187
|
requestedAt: raw.requestedAt,
|
|
153
188
|
etaLabel: raw.etaLabel,
|
|
189
|
+
...(raw.jobId ? { jobId: raw.jobId } : {}),
|
|
154
190
|
...(raw.settledAt ? { settledAt: raw.settledAt } : {}),
|
|
155
191
|
...(raw.error ? { error: raw.error } : {}),
|
|
156
192
|
}
|
|
157
|
-
if (raw.status
|
|
193
|
+
if ((raw.status === 'settled' || raw.status === 'failed') && raw.deliverableFile === `${raw.id}.deliverable.md`) {
|
|
158
194
|
try {
|
|
159
195
|
const text = await readFile(join(dir, raw.deliverableFile), 'utf-8')
|
|
196
|
+
view.deliverablePath = resolve(dir, raw.deliverableFile)
|
|
160
197
|
view.deliverableExcerpt = text.length > 600 ? `${text.slice(0, 600)}…` : text
|
|
161
198
|
} catch { /* 交付物缺失时视图降级,不阻塞列表 */ }
|
|
162
199
|
}
|
|
@@ -200,7 +237,12 @@ function exhaustionResult(reason: string): ToolExecutionResult {
|
|
|
200
237
|
}
|
|
201
238
|
|
|
202
239
|
function handoffResult(ticket: TurnHandoffTicket): ToolExecutionResult {
|
|
203
|
-
const
|
|
240
|
+
const state = ticket.status === 'running' && ticket.jobId
|
|
241
|
+
? `Background planning started as native background job ${ticket.jobId} (estimated ${ticket.etaLabel}; not a guarantee). Track or cancel with job_list/job_output/job_kill.`
|
|
242
|
+
: ticket.status === 'failed'
|
|
243
|
+
? `Background planning could not start: ${ticket.error ?? 'unknown failure'}. Tell the user it failed; do not promise later delivery.`
|
|
244
|
+
: 'The ticket is queued; the background worker has not started. Tell the user it is pending, not running; do not promise a completion time.'
|
|
245
|
+
const instruction = `Async ticket ${ticket.id} persisted. ${state} Stop research and answer from existing results. Never claim a file was created when its tool call failed or was refused. For any successfully written output, call present with its exact existing path before the final response. Mention ticket ${ticket.id}.`
|
|
204
246
|
return {
|
|
205
247
|
isError: true,
|
|
206
248
|
error: {
|
|
@@ -221,6 +263,7 @@ type AgentTurnState = {
|
|
|
221
263
|
startedAt: number
|
|
222
264
|
policy: TurnPolicy
|
|
223
265
|
userMessage?: string
|
|
266
|
+
handoff?: Promise<TurnHandoffTicket>
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
/**
|
|
@@ -250,6 +293,7 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
250
293
|
|
|
251
294
|
const stateByAgent = new Map<string, AgentTurnState>()
|
|
252
295
|
const softExecutions = new Map<ToolExecutionToken, { agentId: string; exit: string }>()
|
|
296
|
+
const handoffExecutions = new Map<ToolExecutionToken, { agentId: string; contexts: NonNullable<ToolExecutionResult['additionalContexts']> }>()
|
|
253
297
|
const finalOnlyDisposers = new Map<string, () => void>()
|
|
254
298
|
|
|
255
299
|
const clearAgent = (agentId: string) => {
|
|
@@ -257,6 +301,9 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
257
301
|
for (const [token, owner] of softExecutions) {
|
|
258
302
|
if (owner.agentId === agentId) softExecutions.delete(token)
|
|
259
303
|
}
|
|
304
|
+
for (const [token, owner] of handoffExecutions) {
|
|
305
|
+
if (owner.agentId === agentId) handoffExecutions.delete(token)
|
|
306
|
+
}
|
|
260
307
|
finalOnlyDisposers.get(agentId)?.()
|
|
261
308
|
finalOnlyDisposers.delete(agentId)
|
|
262
309
|
}
|
|
@@ -273,7 +320,7 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
273
320
|
}
|
|
274
321
|
const inheritedNames = globalTools.schemas()
|
|
275
322
|
.map(schema => schema.name)
|
|
276
|
-
.filter(name => name !== 'run_code')
|
|
323
|
+
.filter(name => name !== 'run_code' && name !== 'present')
|
|
277
324
|
if (inheritedNames.length === 0) return
|
|
278
325
|
finalOnlyDisposers.set(agentId, scopedTools.restrict({ deny: inheritedNames }))
|
|
279
326
|
}
|
|
@@ -324,18 +371,61 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
324
371
|
?? { startedAt: now(), policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs } }
|
|
325
372
|
stateByAgent.set(agentId, state)
|
|
326
373
|
|
|
374
|
+
// Present validates files on disk and publishes native deliverable events;
|
|
375
|
+
// it must remain callable after the research window closes.
|
|
376
|
+
if (exec.name === 'present') return next()
|
|
377
|
+
|
|
327
378
|
const elapsed = now() - state.startedAt
|
|
328
379
|
if (elapsed > state.policy.hardMs) {
|
|
329
|
-
// Synchronous schema suppression so same-step follow-up calls cannot
|
|
330
|
-
// loop on refusal results (the 2026-09-02 trajectory failure).
|
|
331
|
-
enterFinalOnly(exec.agent)
|
|
332
380
|
if (state.policy.exit === 'handoff' && state.userMessage && options.stateRoot) {
|
|
333
|
-
|
|
334
|
-
.
|
|
381
|
+
const root = options.stateRoot === '.'
|
|
382
|
+
? (exec.agent.session?.header.cwd ?? process.cwd()) : resolve(options.stateRoot)
|
|
383
|
+
const written = exec.name === 'write' && !state.handoff ? Promise.resolve().then(() => next()) : undefined
|
|
384
|
+
if (!state.handoff) {
|
|
385
|
+
const session = exec.agent.session
|
|
386
|
+
// Reserve the handoff before executing the write body. Parallel or
|
|
387
|
+
// repeated calls see the same promise and cannot write/dispatch twice.
|
|
388
|
+
state.handoff = (async () => {
|
|
389
|
+
try { await written } finally { enterFinalOnly(exec.agent!) }
|
|
390
|
+
let context: string | undefined
|
|
391
|
+
let contextError: string | undefined
|
|
392
|
+
try { context = handoffContext(session?.snapshotEvents() ?? []) }
|
|
393
|
+
catch (error) { contextError = error instanceof Error ? error.message : 'handoff context unavailable' }
|
|
394
|
+
const ticket = await writeTurnHandoffTicket(root, state.userMessage!, {
|
|
395
|
+
workspaceCwd: session?.header.cwd,
|
|
396
|
+
ownerSessionId: session ? String(session.id) : undefined,
|
|
397
|
+
context,
|
|
398
|
+
})
|
|
399
|
+
if (contextError) {
|
|
400
|
+
await settleTurnHandoffTicket(root, ticket, 'failed', `# Planning paused\n\n${contextError}`, contextError)
|
|
401
|
+
return { ...ticket, status: 'failed', error: contextError }
|
|
402
|
+
}
|
|
403
|
+
return options.startHandoff ? options.startHandoff(ticket, exec.agent!, root) : ticket
|
|
404
|
+
})()
|
|
405
|
+
}
|
|
406
|
+
// The triggering call may be the output write itself. Execute it once,
|
|
407
|
+
// preserve its actual outcome, then require native present. Attach a
|
|
408
|
+
// rejection handler now even if the pending tool itself rejects.
|
|
409
|
+
const handoff = state.handoff.then(handoffResult, () => exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: ticket persistence failed; no background worker was started`))
|
|
410
|
+
return (async () => {
|
|
411
|
+
const output = await written
|
|
412
|
+
const guidance = await handoff
|
|
413
|
+
if (output) {
|
|
414
|
+
handoffExecutions.set(exec.token, { agentId, contexts: guidance.additionalContexts ?? [] })
|
|
415
|
+
// Keep the registry's canonical result object. Rebuilding it after
|
|
416
|
+
// restricting inherited schemas would make DSH re-resolve a tool
|
|
417
|
+
// that has just been hidden and report UNKNOWN_TOOL after saving.
|
|
418
|
+
return output
|
|
419
|
+
}
|
|
420
|
+
return guidance
|
|
421
|
+
})()
|
|
335
422
|
.catch(() => exhaustionResult(
|
|
336
423
|
`${TURN_DEADLINE_EXHAUSTED}: hard deadline ${state.policy.hardMs}ms reached (ticket persistence failed; degraded to converge)`,
|
|
337
424
|
))
|
|
338
425
|
}
|
|
426
|
+
// Suppress inherited research schemas immediately on refusal; DSH's
|
|
427
|
+
// scoped schemas remain visible but hit this same per-call guard.
|
|
428
|
+
enterFinalOnly(exec.agent)
|
|
339
429
|
return Promise.resolve(exhaustionResult(
|
|
340
430
|
`${TURN_DEADLINE_EXHAUSTED}: turn exceeded hard deadline of ${state.policy.hardMs}ms (elapsed ${elapsed}ms); provide a final answer using existing results`,
|
|
341
431
|
))
|
|
@@ -349,16 +439,19 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
349
439
|
ctx.on('tools/post-execute', (exec, _result, next) => {
|
|
350
440
|
return next().then(decision => {
|
|
351
441
|
const owner = softExecutions.get(exec.token)
|
|
352
|
-
|
|
442
|
+
const handoff = handoffExecutions.get(exec.token)
|
|
443
|
+
handoffExecutions.delete(exec.token)
|
|
444
|
+
if (!owner && !handoff) return decision
|
|
353
445
|
softExecutions.delete(exec.token)
|
|
354
|
-
const text = owner
|
|
446
|
+
const text = owner?.exit === 'handoff'
|
|
355
447
|
? 'Turn has crossed the soft deadline of a deep-planning turn. Wrap up context gathering now; the turn will hand off to background planning at the hard deadline.'
|
|
356
448
|
: 'Turn has crossed the soft deadline. Stop dispatching new tools and produce the final answer from gathered evidence.'
|
|
357
449
|
return {
|
|
358
450
|
...decision,
|
|
359
451
|
additionalContexts: [
|
|
360
452
|
...(decision.additionalContexts ?? []),
|
|
361
|
-
|
|
453
|
+
...(handoff?.contexts ?? []),
|
|
454
|
+
...(owner ? [convergenceContext(TURN_DEADLINE_SOFT, text) as never] : []),
|
|
362
455
|
],
|
|
363
456
|
}
|
|
364
457
|
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Connect durable planning tickets to DSH's owned jobs and managed processes. */
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
7
|
+
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
|
|
8
|
+
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
|
9
|
+
import { settleTurnHandoffTicket, writeTicketJson, type TurnHandoffTicket } from './turn-deadline.ts'
|
|
10
|
+
|
|
11
|
+
declare module '@deepseek-ai/dsh-jobs' {
|
|
12
|
+
interface JobKindMap { 'gotry-planning': 'gotry-planning' }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
|
|
17
|
+
export async function startTurnHandoffJob(
|
|
18
|
+
ctx: Context, ticket: TurnHandoffTicket, owner: Agent, stateRoot: string,
|
|
19
|
+
): Promise<TurnHandoffTicket> {
|
|
20
|
+
if (ticket.status !== 'open') return ticket
|
|
21
|
+
const jobs = ctx.get('jobs')
|
|
22
|
+
const subprocess = ctx.get('subprocess')
|
|
23
|
+
if (!jobs || !subprocess) return ticket // Queued, never announced as running.
|
|
24
|
+
let current = ticket
|
|
25
|
+
let handle: SubprocessHandle | undefined
|
|
26
|
+
let cancelled = false
|
|
27
|
+
let release!: (ok: boolean) => void
|
|
28
|
+
const prepared = new Promise<boolean>(resolve => { release = resolve })
|
|
29
|
+
let announceStarted!: () => void
|
|
30
|
+
const started = new Promise<void>(resolve => { announceStarted = resolve })
|
|
31
|
+
const fail = async (reason: string) => {
|
|
32
|
+
await settleTurnHandoffTicket(stateRoot, current, 'failed', `# Planning failed\n\n${reason}\n\nNo completed plan was produced.`, reason)
|
|
33
|
+
current = { ...current, status: 'failed', error: reason }
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const jobId = jobs.start({
|
|
37
|
+
kind: 'gotry-planning', owner: owner.id, label: `${ticket.id}: ${ticket.objective.slice(0, 100)}`, outputLimitBytes: 12_000,
|
|
38
|
+
run() {
|
|
39
|
+
return {
|
|
40
|
+
cancel() { cancelled = true; handle?.terminate() },
|
|
41
|
+
done: (async (): Promise<JobOutcome> => {
|
|
42
|
+
if (!await prepared) { announceStarted(); return { status: 'failed', detail: 'ticket persistence failed' } }
|
|
43
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
try {
|
|
45
|
+
if (cancelled) throw new Error('planning cancelled')
|
|
46
|
+
const extension = import.meta.url.endsWith('.ts') ? 'ts' : 'js'
|
|
47
|
+
const collector = join(import.meta.dirname, '..', 'scripts', `turn-handoff-collect.${extension}`)
|
|
48
|
+
const argv = extension === 'ts'
|
|
49
|
+
? [process.execPath, require.resolve('tsx/cli'), collector, ticket.id, stateRoot]
|
|
50
|
+
: [process.execPath, collector, ticket.id, stateRoot]
|
|
51
|
+
handle = subprocess.spawn({
|
|
52
|
+
argv, cwd: ticket.workspaceCwd ?? stateRoot,
|
|
53
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 16_000 }, stderr: { maxBytes: 16_000 } },
|
|
54
|
+
// Collector cleanup includes the wrapper's detached DSH group
|
|
55
|
+
// (5s+1s), then its own 8s+1s bound and durable settlement.
|
|
56
|
+
graceMs: 11_000,
|
|
57
|
+
// This dedicated planning child inherits the configured model
|
|
58
|
+
// environment explicitly; secrets never enter tickets or output.
|
|
59
|
+
env: { GOTRY_HANDOFF_JOB_ID: current.jobId,
|
|
60
|
+
GOTRY_HANDOFF_PLANNER_BIN: process.env.GOTRY_HANDOFF_PLANNER_BIN,
|
|
61
|
+
GOTRY_HANDOFF_PLANNER_TIMEOUT_MS: process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS,
|
|
62
|
+
DSH_HOME: process.env.DSH_HOME,
|
|
63
|
+
LLM_API_KEY: process.env.LLM_API_KEY, LLM_BASE_URL: process.env.LLM_BASE_URL,
|
|
64
|
+
LLM_MODEL: process.env.LLM_MODEL, GOTRY_LLM_MODEL: process.env.GOTRY_LLM_MODEL,
|
|
65
|
+
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL },
|
|
66
|
+
})
|
|
67
|
+
announceStarted()
|
|
68
|
+
const plannerTimeout = Number(process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS)
|
|
69
|
+
const timeout = Number.isSafeInteger(plannerTimeout) && plannerTimeout > 0 ? plannerTimeout : 900_000
|
|
70
|
+
timer = setTimeout(() => handle!.terminate(), timeout + 12_000)
|
|
71
|
+
const direct = await handle.done.then(
|
|
72
|
+
outcome => ({ ok: true as const, outcome }),
|
|
73
|
+
error => ({ ok: false as const, error }),
|
|
74
|
+
)
|
|
75
|
+
// Direct success or failure does not prove managed-range exit.
|
|
76
|
+
if (!direct.ok) handle.terminate()
|
|
77
|
+
try { await handle.waitForExit() }
|
|
78
|
+
catch (cleanupError) {
|
|
79
|
+
handle.terminate()
|
|
80
|
+
const cleanupDiagnostic = cleanupError instanceof Error ? cleanupError.message : 'unknown cleanup error'
|
|
81
|
+
const cleanupReason = `managed-range cleanup could not be confirmed: ${cleanupDiagnostic}`
|
|
82
|
+
if (!direct.ok) {
|
|
83
|
+
const diagnostic = direct.error instanceof Error ? direct.error.message : 'planning failed'
|
|
84
|
+
throw new AggregateError([direct.error, cleanupError], `${diagnostic}; ${cleanupReason}`)
|
|
85
|
+
}
|
|
86
|
+
throw new Error(cleanupReason, { cause: cleanupError })
|
|
87
|
+
}
|
|
88
|
+
if (!direct.ok) throw direct.error
|
|
89
|
+
const outcome = direct.outcome
|
|
90
|
+
const path = join(stateRoot, 'gotry-state', 'turn-handoffs', `${ticket.id}.json`)
|
|
91
|
+
const settled = JSON.parse(await readFile(path, 'utf8')) as TurnHandoffTicket
|
|
92
|
+
if (cancelled) throw new Error('planning cancelled')
|
|
93
|
+
if (outcome.exitCode !== 0 || settled.status !== 'settled' || settled.deliverableFile !== `${ticket.id}.deliverable.md`) {
|
|
94
|
+
throw new Error(settled.error ?? `planning collector failed (exit ${outcome.exitCode ?? outcome.signal})`)
|
|
95
|
+
}
|
|
96
|
+
const deliverablePath = join(stateRoot, 'gotry-state', 'turn-handoffs', settled.deliverableFile)
|
|
97
|
+
const content = await readFile(deliverablePath, 'utf8')
|
|
98
|
+
if (!content.trim()) throw new Error('planning collector produced an empty deliverable')
|
|
99
|
+
current = settled
|
|
100
|
+
return { status: 'completed', detail: `ticket ${ticket.id} settled`, result: `Planning completed: ${ticket.id}. Deliverable: ${deliverablePath}\nCall present with this exact path, then report the result.\n\n${content.slice(0, 8_000)}` }
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const reason = error instanceof Error ? error.message : 'planning failed'
|
|
103
|
+
await fail(reason)
|
|
104
|
+
return { status: cancelled ? 'killed' : 'failed', detail: reason, result: `Ticket ${ticket.id} failed: ${reason}` }
|
|
105
|
+
} finally { if (timer) clearTimeout(timer); announceStarted() }
|
|
106
|
+
})(),
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
current = { ...ticket, status: 'running', jobId }
|
|
111
|
+
await writeTicketJson(stateRoot, current)
|
|
112
|
+
release(true)
|
|
113
|
+
await started
|
|
114
|
+
return current
|
|
115
|
+
} catch (error) {
|
|
116
|
+
release(false)
|
|
117
|
+
await fail(error instanceof Error ? error.message : 'background job registration failed')
|
|
118
|
+
return current
|
|
119
|
+
}
|
|
120
|
+
}
|