@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.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +56 -83
  4. package/bin/gotry-extension-status.d.ts +12 -0
  5. package/bin/gotry-extension-status.js +113 -0
  6. package/bin/gotry-flyai-verification.d.ts +9 -0
  7. package/bin/gotry-flyai-verification.js +63 -0
  8. package/bin/gotry-inner.js +9 -2
  9. package/client/client.js +183 -0
  10. package/cordis.gotry-patch.yml +2 -0
  11. package/dist/capabilities/artifacts.js +41 -4
  12. package/dist/capabilities/doctor.js +7 -15
  13. package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
  14. package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
  15. package/dist/scripts/artifact-client-contract-tests.js +4 -3
  16. package/dist/scripts/artifact-delivery-tests.js +31 -0
  17. package/dist/scripts/bootstrap-tests.js +19 -4
  18. package/dist/scripts/doctor-tests.js +36 -7
  19. package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
  20. package/dist/scripts/flyai-setup-tests.js +75 -2
  21. package/dist/scripts/gotry-web-api-tests.js +114 -0
  22. package/dist/scripts/gotry-web-client-tests.js +28 -0
  23. package/dist/scripts/issue-271-liveness-tests.js +9 -1
  24. package/dist/scripts/turn-handoff-collect-tests.js +7 -1
  25. package/dist/scripts/turn-handoff-collect.js +68 -20
  26. package/dist/scripts/turn-handoff-job-tests.js +222 -0
  27. package/dist/scripts/turn-handoff-web-e2e.js +240 -0
  28. package/dist/src/artifact-delivery.js +26 -0
  29. package/dist/src/gotry-web-api.js +157 -0
  30. package/dist/src/index.js +30 -8
  31. package/dist/src/turn-deadline.js +113 -16
  32. package/dist/src/turn-handoff-job.js +124 -0
  33. package/extension/manifest.json +2 -2
  34. package/package.json +8 -2
  35. package/ts/capabilities/artifacts.ts +40 -5
  36. package/ts/scripts/turn-handoff-collect.ts +63 -21
  37. package/ts/src/artifact-delivery.ts +25 -0
  38. package/ts/src/gotry-web-api.ts +140 -0
  39. package/ts/src/index.ts +30 -8
  40. package/ts/src/turn-deadline.ts +111 -18
  41. package/ts/src/turn-handoff-job.ts +120 -0
@@ -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
- * agent tick(loopx),不是 async-collect。
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
- export async function writeTurnHandoffTicket(stateRoot: string, userMessage: string): Promise<TurnHandoffTicket> {
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 !== 'open' && raw.deliverableFile) {
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 instruction = `Turn handed off to background planning. Async ticket ${ticket.id} created (ETA ${ticket.etaLabel}). Stop dispatching tools and tell the user in one short message: the deep planning continues in the background, the expected time, and to check back later (mention ticket ${ticket.id}).`
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
- return writeTurnHandoffTicket(options.stateRoot, state.userMessage)
334
- .then(ticket => handoffResult(ticket))
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
- if (!owner) return decision
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.exit === 'handoff'
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
- convergenceContext(TURN_DEADLINE_SOFT, text) as never,
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
+ }