@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
package/dist/src/index.js CHANGED
@@ -42,12 +42,15 @@ import { generateItineraryDeckExport } from '../capabilities/itinerary-deck-expo
42
42
  import { interpretEffect, declinedObservation } from '../capabilities/effect.js';
43
43
  import { customerSendCode, customerLogin } from '../capabilities/hbcli.js';
44
44
  import { FLYAI_POI_CATEGORIES } from '../capabilities/flyai.js';
45
+ import { registerGotryWebApi } from './gotry-web-api.js';
46
+ import { registerArtifactDeliveries } from './artifact-delivery.js';
45
47
  import { createFlyaiSetupTool } from './flyai-setup-tool.js';
46
48
  import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.js';
47
49
  import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.js';
48
50
  import { hasRecognizedAvailableSeat } from '../capabilities/session/adapters/rail-12306.js';
49
51
  import { gateArtifact } from './artifact-gate.js';
50
52
  import { installTurnDeadline, listTurnHandoffTickets } from './turn-deadline.js';
53
+ import { startTurnHandoffJob } from './turn-handoff-job.js';
51
54
  import { noteChannelVerdict, recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.js';
52
55
  import { routingAdvice, renderRoutingCard, toolRoutingHeadline, persistedDownChannels } from '../capabilities/channel-registry.js';
53
56
  import { registerBenchmarkEnvironmentBridge } from './benchmark-environment-bridge.js';
@@ -428,12 +431,20 @@ export function apply(ctx, config, seams = {}) {
428
431
  // 固定 policy 保证评测可复现,不走路由。GOTRY_HANDOFF_CHILD=1 是收集器
429
432
  // 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
430
433
  // 必须产出最终交付物,不得再次 handoff(否则无限递归)。
434
+ if (!rawBenchmarkEnvironmentConfigPath) {
435
+ registerGotryWebApi(ctx, config.stateRoot ?? '.');
436
+ registerArtifactDeliveries(ctx);
437
+ }
431
438
  const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1';
432
439
  installTurnDeadline(ctx, handoffChild
433
440
  ? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
434
441
  : rawBenchmarkEnvironmentConfigPath.trim()
435
442
  ? { fixedPolicy: benchmarkPinnedPolicy() }
436
- : { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.' });
443
+ : { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.',
444
+ // A one-shot headless process exits after its final response and cannot
445
+ // own ongoing work. Keep that ticket queued for explicit collection.
446
+ startHandoff: (ticket, agent, root) => typeof ctx.get === 'function' && ctx.get('connection')
447
+ ? startTurnHandoffJob(ctx, ticket, agent, root) : Promise.resolve(ticket) });
437
448
  if (rawBenchmarkEnvironmentConfigPath.trim()) {
438
449
  // Benchmark mode is a deliberately minimal kernel: only the model
439
450
  // override and the environment bridge are installed besides the pinned
@@ -987,8 +998,8 @@ export function apply(ctx, config, seams = {}) {
987
998
  registerGuarded(defineTool({
988
999
  name: 'gotry_turn_handoff_list',
989
1000
  description: 'List background deep-planning handoff tickets (read-only). Call this whenever the user asks about '
990
- + 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open = still being worked in the '
991
- + 'background (ETA ' + '约 1 小时' + '), settled = deliverable ready (excerpt included, full text in the '
1001
+ + 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open = queued, worker not started; '
1002
+ + 'running = started with a native job id (use job_list for live status); settled = deliverable ready (excerpt included, full text in the '
992
1003
  + 'ticket\'s .deliverable.md), failed = honest failure note. Never fabricate a deliverable that is not here.',
993
1004
  // D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层(同 session 刀法)
994
1005
  parameters: {
@@ -998,9 +1009,10 @@ export function apply(ctx, config, seams = {}) {
998
1009
  schema: { type: 'json' },
999
1010
  render: (_args, value) => [{ type: 'text', text: String(value.summary ?? '') }],
1000
1011
  },
1001
- async execute(args, _exec) {
1012
+ async execute(args, exec) {
1002
1013
  const q = interpretArgs(args);
1003
- const root = process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.';
1014
+ const configuredRoot = process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.';
1015
+ const root = configuredRoot === '.' ? sessionCwd(exec) ?? '.' : configuredRoot;
1004
1016
  const all = await listTurnHandoffTickets(root);
1005
1017
  const tickets = (q.ticketId ? all.filter(t => t.id === q.ticketId) : all).slice(0, 10);
1006
1018
  if (tickets.length === 0) {
@@ -1014,10 +1026,19 @@ export function apply(ctx, config, seams = {}) {
1014
1026
  const lines = tickets.map(t => {
1015
1027
  const head = `[${t.status}] ${t.id} ${t.objective.slice(0, 40)}`;
1016
1028
  if (t.status === 'open')
1017
- return `${head}——后台规划中,ETA ${t.etaLabel}`;
1029
+ return `${head}——待启动,后台尚未运行`;
1030
+ if (t.status === 'running') {
1031
+ const owner = exec.agent;
1032
+ // Native counters restart with the host. A recycled id alone is not
1033
+ // the durable ticket's identity and must not offer another trip's job.
1034
+ const live = owner && ctx.get('jobs')?.list(owner.id).some(job => job.id === t.jobId
1035
+ && job.owner === owner.id && job.kind === 'gotry-planning' && job.label.startsWith(`${t.id}: `)
1036
+ && (job.status === 'running' || job.status === 'stopping'));
1037
+ return `${head}——${live ? `后台任务 ${t.jobId},预计 ${t.etaLabel}` : '运行记录已中断,当前没有活跃后台任务'} `;
1038
+ }
1018
1039
  if (t.status === 'failed')
1019
1040
  return `${head}——失败:${t.error ?? '未知原因'}(请重新发起规划)`;
1020
- return `${head}——已交付,摘要:${(t.deliverableExcerpt ?? '').slice(0, 120).replace(/\n/g, ' ')}`;
1041
+ return `${head}——${t.deliverablePath ? `已交付:${t.deliverablePath}` : '交付文件缺失'},摘要:${(t.deliverableExcerpt ?? '').slice(0, 120).replace(/\n/g, ' ')}`;
1021
1042
  });
1022
1043
  return {
1023
1044
  ok: true, count: tickets.length, tickets,
@@ -2355,6 +2376,7 @@ export function apply(ctx, config, seams = {}) {
2355
2376
  try {
2356
2377
  r = await listArtifacts({
2357
2378
  stateRoot: config.stateRoot ?? '.',
2379
+ handoffRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? ((config.stateRoot ?? '.') === '.' ? sessionCwd(exec) ?? '.' : config.stateRoot),
2358
2380
  cwd: sessionCwd(exec),
2359
2381
  limit: q.limit,
2360
2382
  offset: q.offset,
@@ -2448,7 +2470,7 @@ export function apply(ctx, config, seams = {}) {
2448
2470
  throw new Error('gotry_artifacts_read requires path');
2449
2471
  if (!q.path)
2450
2472
  return JSON.parse(JSON.stringify({ ok: false, error: 'path 必填(来自 gotry_artifacts_list)' }));
2451
- const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit });
2473
+ const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', handoffRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? ((config.stateRoot ?? '.') === '.' ? sessionCwd(exec) ?? '.' : config.stateRoot), cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit });
2452
2474
  if (!r.ok)
2453
2475
  return JSON.parse(JSON.stringify(r));
2454
2476
  // 身份/来源展示(issue #285 第 1 条「显示身份/来源」):行号视图保留 read 卡的
@@ -13,18 +13,47 @@
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
  import { randomUUID } from 'node:crypto';
20
20
  import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
21
- import { join } from 'node:path';
21
+ import { join, resolve } from 'node:path';
22
22
  import { classifyTurn, TURN_HANDOFF_ETA_LABEL, turnPolicyFor } from './turn-policy.js';
23
23
  export const TURN_DEADLINE_SOFT = 'TURN_DEADLINE_SOFT';
24
24
  export const TURN_DEADLINE_EXHAUSTED = 'TURN_DEADLINE_EXHAUSTED';
25
25
  export const TURN_DEADLINE_HANDOFF = 'TURN_DEADLINE_HANDOFF';
26
26
  export const TURN_HANDOFF_SCHEMA = 'gotry_turn_handoff.v1';
27
- export async function writeTurnHandoffTicket(stateRoot, userMessage) {
27
+ function handoffContext(events) {
28
+ const questionIds = new Set(events.filter(event => event.type === 'tool/call' && event.data.name === 'ask_user_question')
29
+ .map(event => event.type === 'tool/call' ? event.data.callId : ''));
30
+ // Human constraints are lossless. Refuse dispatch if they exceed the input
31
+ // budget; silently cutting an accessibility/budget answer changes the task.
32
+ const answers = events.filter(event => (event.type === 'user/message' && event.data.source.kind === 'user')
33
+ || (event.type === 'tool/call' && questionIds.has(event.data.callId))
34
+ || (event.type === 'tool/result' && event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId)))
35
+ .map(event => JSON.stringify(event.data)).join('\n');
36
+ if (answers.length > 64_000)
37
+ 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.');
38
+ let remaining = 18_000;
39
+ const evidence = [];
40
+ for (const event of [...events].reverse()) {
41
+ if (event.type !== 'tool/result' || (event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId)))
42
+ continue;
43
+ const row = JSON.stringify(event.data);
44
+ // Evidence may be excerpted, with an explicit marker and valid JSON.
45
+ const excerpt = row.length > remaining
46
+ ? JSON.stringify({ truncated: true, excerpt: row.slice(-Math.floor(remaining / 2)) }) : row;
47
+ if (excerpt.length + 1 > remaining)
48
+ break;
49
+ evidence.unshift(excerpt);
50
+ remaining -= excerpt.length + 1;
51
+ if (remaining < 100)
52
+ break;
53
+ }
54
+ return answers || evidence.length ? `${answers}\n${evidence.join('\n')}` : undefined;
55
+ }
56
+ export async function writeTurnHandoffTicket(stateRoot, userMessage, details = {}) {
28
57
  const ticket = {
29
58
  schema: TURN_HANDOFF_SCHEMA,
30
59
  id: `th-${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`,
@@ -33,6 +62,7 @@ export async function writeTurnHandoffTicket(stateRoot, userMessage) {
33
62
  requestedAt: new Date().toISOString(),
34
63
  etaLabel: TURN_HANDOFF_ETA_LABEL,
35
64
  status: 'open',
65
+ ...details,
36
66
  };
37
67
  await writeTicketJson(stateRoot, ticket);
38
68
  return ticket;
@@ -41,7 +71,7 @@ function handoffDir(stateRoot) {
41
71
  const root = stateRoot === '.' ? process.cwd() : stateRoot;
42
72
  return join(root, 'gotry-state', 'turn-handoffs');
43
73
  }
44
- async function writeTicketJson(stateRoot, ticket) {
74
+ export async function writeTicketJson(stateRoot, ticket) {
45
75
  const dir = handoffDir(stateRoot);
46
76
  await mkdir(dir, { recursive: true });
47
77
  const path = join(dir, `${ticket.id}.json`);
@@ -95,12 +125,14 @@ export async function listTurnHandoffTickets(stateRoot) {
95
125
  objective: raw.objective,
96
126
  requestedAt: raw.requestedAt,
97
127
  etaLabel: raw.etaLabel,
128
+ ...(raw.jobId ? { jobId: raw.jobId } : {}),
98
129
  ...(raw.settledAt ? { settledAt: raw.settledAt } : {}),
99
130
  ...(raw.error ? { error: raw.error } : {}),
100
131
  };
101
- if (raw.status !== 'open' && raw.deliverableFile) {
132
+ if ((raw.status === 'settled' || raw.status === 'failed') && raw.deliverableFile === `${raw.id}.deliverable.md`) {
102
133
  try {
103
134
  const text = await readFile(join(dir, raw.deliverableFile), 'utf-8');
135
+ view.deliverablePath = resolve(dir, raw.deliverableFile);
104
136
  view.deliverableExcerpt = text.length > 600 ? `${text.slice(0, 600)}…` : text;
105
137
  }
106
138
  catch { /* 交付物缺失时视图降级,不阻塞列表 */ }
@@ -140,7 +172,12 @@ function exhaustionResult(reason) {
140
172
  };
141
173
  }
142
174
  function handoffResult(ticket) {
143
- 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}).`;
175
+ const state = ticket.status === 'running' && ticket.jobId
176
+ ? `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.`
177
+ : ticket.status === 'failed'
178
+ ? `Background planning could not start: ${ticket.error ?? 'unknown failure'}. Tell the user it failed; do not promise later delivery.`
179
+ : 'The ticket is queued; the background worker has not started. Tell the user it is pending, not running; do not promise a completion time.';
180
+ 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}.`;
144
181
  return {
145
182
  isError: true,
146
183
  error: {
@@ -177,6 +214,7 @@ export function installTurnDeadline(ctx, options = {}) {
177
214
  };
178
215
  const stateByAgent = new Map();
179
216
  const softExecutions = new Map();
217
+ const handoffExecutions = new Map();
180
218
  const finalOnlyDisposers = new Map();
181
219
  const clearAgent = (agentId) => {
182
220
  stateByAgent.delete(agentId);
@@ -184,6 +222,10 @@ export function installTurnDeadline(ctx, options = {}) {
184
222
  if (owner.agentId === agentId)
185
223
  softExecutions.delete(token);
186
224
  }
225
+ for (const [token, owner] of handoffExecutions) {
226
+ if (owner.agentId === agentId)
227
+ handoffExecutions.delete(token);
228
+ }
187
229
  finalOnlyDisposers.get(agentId)?.();
188
230
  finalOnlyDisposers.delete(agentId);
189
231
  };
@@ -200,7 +242,7 @@ export function installTurnDeadline(ctx, options = {}) {
200
242
  }
201
243
  const inheritedNames = globalTools.schemas()
202
244
  .map(schema => schema.name)
203
- .filter(name => name !== 'run_code');
245
+ .filter(name => name !== 'run_code' && name !== 'present');
204
246
  if (inheritedNames.length === 0)
205
247
  return;
206
248
  finalOnlyDisposers.set(agentId, scopedTools.restrict({ deny: inheritedNames }));
@@ -249,16 +291,68 @@ export function installTurnDeadline(ctx, options = {}) {
249
291
  const state = stateByAgent.get(agentId)
250
292
  ?? { startedAt: now(), policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs } };
251
293
  stateByAgent.set(agentId, state);
294
+ // Present validates files on disk and publishes native deliverable events;
295
+ // it must remain callable after the research window closes.
296
+ if (exec.name === 'present')
297
+ return next();
252
298
  const elapsed = now() - state.startedAt;
253
299
  if (elapsed > state.policy.hardMs) {
254
- // Synchronous schema suppression so same-step follow-up calls cannot
255
- // loop on refusal results (the 2026-09-02 trajectory failure).
256
- enterFinalOnly(exec.agent);
257
300
  if (state.policy.exit === 'handoff' && state.userMessage && options.stateRoot) {
258
- return writeTurnHandoffTicket(options.stateRoot, state.userMessage)
259
- .then(ticket => handoffResult(ticket))
301
+ const root = options.stateRoot === '.'
302
+ ? (exec.agent.session?.header.cwd ?? process.cwd()) : resolve(options.stateRoot);
303
+ const written = exec.name === 'write' && !state.handoff ? Promise.resolve().then(() => next()) : undefined;
304
+ if (!state.handoff) {
305
+ const session = exec.agent.session;
306
+ // Reserve the handoff before executing the write body. Parallel or
307
+ // repeated calls see the same promise and cannot write/dispatch twice.
308
+ state.handoff = (async () => {
309
+ try {
310
+ await written;
311
+ }
312
+ finally {
313
+ enterFinalOnly(exec.agent);
314
+ }
315
+ let context;
316
+ let contextError;
317
+ try {
318
+ context = handoffContext(session?.snapshotEvents() ?? []);
319
+ }
320
+ catch (error) {
321
+ contextError = error instanceof Error ? error.message : 'handoff context unavailable';
322
+ }
323
+ const ticket = await writeTurnHandoffTicket(root, state.userMessage, {
324
+ workspaceCwd: session?.header.cwd,
325
+ ownerSessionId: session ? String(session.id) : undefined,
326
+ context,
327
+ });
328
+ if (contextError) {
329
+ await settleTurnHandoffTicket(root, ticket, 'failed', `# Planning paused\n\n${contextError}`, contextError);
330
+ return { ...ticket, status: 'failed', error: contextError };
331
+ }
332
+ return options.startHandoff ? options.startHandoff(ticket, exec.agent, root) : ticket;
333
+ })();
334
+ }
335
+ // The triggering call may be the output write itself. Execute it once,
336
+ // preserve its actual outcome, then require native present. Attach a
337
+ // rejection handler now even if the pending tool itself rejects.
338
+ const handoff = state.handoff.then(handoffResult, () => exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: ticket persistence failed; no background worker was started`));
339
+ return (async () => {
340
+ const output = await written;
341
+ const guidance = await handoff;
342
+ if (output) {
343
+ handoffExecutions.set(exec.token, { agentId, contexts: guidance.additionalContexts ?? [] });
344
+ // Keep the registry's canonical result object. Rebuilding it after
345
+ // restricting inherited schemas would make DSH re-resolve a tool
346
+ // that has just been hidden and report UNKNOWN_TOOL after saving.
347
+ return output;
348
+ }
349
+ return guidance;
350
+ })()
260
351
  .catch(() => exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: hard deadline ${state.policy.hardMs}ms reached (ticket persistence failed; degraded to converge)`));
261
352
  }
353
+ // Suppress inherited research schemas immediately on refusal; DSH's
354
+ // scoped schemas remain visible but hit this same per-call guard.
355
+ enterFinalOnly(exec.agent);
262
356
  return Promise.resolve(exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: turn exceeded hard deadline of ${state.policy.hardMs}ms (elapsed ${elapsed}ms); provide a final answer using existing results`));
263
357
  }
264
358
  if (elapsed > state.policy.softMs) {
@@ -269,17 +363,20 @@ export function installTurnDeadline(ctx, options = {}) {
269
363
  ctx.on('tools/post-execute', (exec, _result, next) => {
270
364
  return next().then(decision => {
271
365
  const owner = softExecutions.get(exec.token);
272
- if (!owner)
366
+ const handoff = handoffExecutions.get(exec.token);
367
+ handoffExecutions.delete(exec.token);
368
+ if (!owner && !handoff)
273
369
  return decision;
274
370
  softExecutions.delete(exec.token);
275
- const text = owner.exit === 'handoff'
371
+ const text = owner?.exit === 'handoff'
276
372
  ? '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.'
277
373
  : 'Turn has crossed the soft deadline. Stop dispatching new tools and produce the final answer from gathered evidence.';
278
374
  return {
279
375
  ...decision,
280
376
  additionalContexts: [
281
377
  ...(decision.additionalContexts ?? []),
282
- convergenceContext(TURN_DEADLINE_SOFT, text),
378
+ ...(handoff?.contexts ?? []),
379
+ ...(owner ? [convergenceContext(TURN_DEADLINE_SOFT, text)] : []),
283
380
  ],
284
381
  };
285
382
  });
@@ -0,0 +1,124 @@
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 { settleTurnHandoffTicket, writeTicketJson } from './turn-deadline.js';
6
+ const require = createRequire(import.meta.url);
7
+ export async function startTurnHandoffJob(ctx, ticket, owner, stateRoot) {
8
+ if (ticket.status !== 'open')
9
+ return ticket;
10
+ const jobs = ctx.get('jobs');
11
+ const subprocess = ctx.get('subprocess');
12
+ if (!jobs || !subprocess)
13
+ return ticket; // Queued, never announced as running.
14
+ let current = ticket;
15
+ let handle;
16
+ let cancelled = false;
17
+ let release;
18
+ const prepared = new Promise(resolve => { release = resolve; });
19
+ let announceStarted;
20
+ const started = new Promise(resolve => { announceStarted = resolve; });
21
+ const fail = async (reason) => {
22
+ await settleTurnHandoffTicket(stateRoot, current, 'failed', `# Planning failed\n\n${reason}\n\nNo completed plan was produced.`, reason);
23
+ current = { ...current, status: 'failed', error: reason };
24
+ };
25
+ try {
26
+ const jobId = jobs.start({
27
+ kind: 'gotry-planning', owner: owner.id, label: `${ticket.id}: ${ticket.objective.slice(0, 100)}`, outputLimitBytes: 12_000,
28
+ run() {
29
+ return {
30
+ cancel() { cancelled = true; handle?.terminate(); },
31
+ done: (async () => {
32
+ if (!await prepared) {
33
+ announceStarted();
34
+ return { status: 'failed', detail: 'ticket persistence failed' };
35
+ }
36
+ let timer;
37
+ try {
38
+ if (cancelled)
39
+ throw new Error('planning cancelled');
40
+ const extension = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
41
+ const collector = join(import.meta.dirname, '..', 'scripts', `turn-handoff-collect.${extension}`);
42
+ const argv = extension === 'ts'
43
+ ? [process.execPath, require.resolve('tsx/cli'), collector, ticket.id, stateRoot]
44
+ : [process.execPath, collector, ticket.id, stateRoot];
45
+ handle = subprocess.spawn({
46
+ argv, cwd: ticket.workspaceCwd ?? stateRoot,
47
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 16_000 }, stderr: { maxBytes: 16_000 } },
48
+ // Collector cleanup includes the wrapper's detached DSH group
49
+ // (5s+1s), then its own 8s+1s bound and durable settlement.
50
+ graceMs: 11_000,
51
+ // This dedicated planning child inherits the configured model
52
+ // environment explicitly; secrets never enter tickets or output.
53
+ env: { GOTRY_HANDOFF_JOB_ID: current.jobId,
54
+ GOTRY_HANDOFF_PLANNER_BIN: process.env.GOTRY_HANDOFF_PLANNER_BIN,
55
+ GOTRY_HANDOFF_PLANNER_TIMEOUT_MS: process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS,
56
+ DSH_HOME: process.env.DSH_HOME,
57
+ LLM_API_KEY: process.env.LLM_API_KEY, LLM_BASE_URL: process.env.LLM_BASE_URL,
58
+ LLM_MODEL: process.env.LLM_MODEL, GOTRY_LLM_MODEL: process.env.GOTRY_LLM_MODEL,
59
+ DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL },
60
+ });
61
+ announceStarted();
62
+ const plannerTimeout = Number(process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS);
63
+ const timeout = Number.isSafeInteger(plannerTimeout) && plannerTimeout > 0 ? plannerTimeout : 900_000;
64
+ timer = setTimeout(() => handle.terminate(), timeout + 12_000);
65
+ const direct = await handle.done.then(outcome => ({ ok: true, outcome }), error => ({ ok: false, error }));
66
+ // Direct success or failure does not prove managed-range exit.
67
+ if (!direct.ok)
68
+ handle.terminate();
69
+ try {
70
+ await handle.waitForExit();
71
+ }
72
+ catch (cleanupError) {
73
+ handle.terminate();
74
+ const cleanupDiagnostic = cleanupError instanceof Error ? cleanupError.message : 'unknown cleanup error';
75
+ const cleanupReason = `managed-range cleanup could not be confirmed: ${cleanupDiagnostic}`;
76
+ if (!direct.ok) {
77
+ const diagnostic = direct.error instanceof Error ? direct.error.message : 'planning failed';
78
+ throw new AggregateError([direct.error, cleanupError], `${diagnostic}; ${cleanupReason}`);
79
+ }
80
+ throw new Error(cleanupReason, { cause: cleanupError });
81
+ }
82
+ if (!direct.ok)
83
+ throw direct.error;
84
+ const outcome = direct.outcome;
85
+ const path = join(stateRoot, 'gotry-state', 'turn-handoffs', `${ticket.id}.json`);
86
+ const settled = JSON.parse(await readFile(path, 'utf8'));
87
+ if (cancelled)
88
+ throw new Error('planning cancelled');
89
+ if (outcome.exitCode !== 0 || settled.status !== 'settled' || settled.deliverableFile !== `${ticket.id}.deliverable.md`) {
90
+ throw new Error(settled.error ?? `planning collector failed (exit ${outcome.exitCode ?? outcome.signal})`);
91
+ }
92
+ const deliverablePath = join(stateRoot, 'gotry-state', 'turn-handoffs', settled.deliverableFile);
93
+ const content = await readFile(deliverablePath, 'utf8');
94
+ if (!content.trim())
95
+ throw new Error('planning collector produced an empty deliverable');
96
+ current = settled;
97
+ 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)}` };
98
+ }
99
+ catch (error) {
100
+ const reason = error instanceof Error ? error.message : 'planning failed';
101
+ await fail(reason);
102
+ return { status: cancelled ? 'killed' : 'failed', detail: reason, result: `Ticket ${ticket.id} failed: ${reason}` };
103
+ }
104
+ finally {
105
+ if (timer)
106
+ clearTimeout(timer);
107
+ announceStarted();
108
+ }
109
+ })(),
110
+ };
111
+ },
112
+ });
113
+ current = { ...ticket, status: 'running', jobId };
114
+ await writeTicketJson(stateRoot, current);
115
+ release(true);
116
+ await started;
117
+ return current;
118
+ }
119
+ catch (error) {
120
+ release(false);
121
+ await fail(error instanceof Error ? error.message : 'background job registration failed');
122
+ return current;
123
+ }
124
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Stai Travel Bridge",
4
- "version": "0.2.0.29",
4
+ "version": "0.2.0.31",
5
5
  "description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
7
7
  "permissions": [
@@ -56,5 +56,5 @@
56
56
  "run_at": "document_start"
57
57
  }
58
58
  ],
59
- "version_name": "0.2.0-rc.29"
59
+ "version_name": "0.2.0-rc.31"
60
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.29",
3
+ "version": "0.2.0-rc.31",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -44,7 +44,10 @@
44
44
  "client": {
45
45
  "platform": "web",
46
46
  "inject": [
47
- "@deepseek-ai/dsh-client-ui-tool"
47
+ "@deepseek-ai/dsh-client-ui-tool",
48
+ "@deepseek-ai/dsh-client-ui-primitives",
49
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
50
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
48
51
  ]
49
52
  }
50
53
  },
@@ -57,6 +60,8 @@
57
60
  "bin/",
58
61
  "ts/src/index.ts",
59
62
  "ts/src/flyai-setup-tool.ts",
63
+ "ts/src/gotry-web-api.ts",
64
+ "ts/src/artifact-delivery.ts",
60
65
  "ts/src/subagent-job-id-guard.ts",
61
66
  "ts/src/benchmark-environment-bridge.ts",
62
67
  "ts/src/benchmark-tool-isolation.ts",
@@ -78,6 +83,7 @@
78
83
  "ts/src/artifact-gate.ts",
79
84
  "ts/src/tool-packet.ts",
80
85
  "ts/src/turn-deadline.ts",
86
+ "ts/src/turn-handoff-job.ts",
81
87
  "ts/src/turn-policy.ts",
82
88
  "ts/src/memory-capture.ts",
83
89
  "ts/src/memory-utility.ts",
@@ -31,7 +31,7 @@ import { isAbsolute, join, resolve, sep } from 'node:path'
31
31
  import { openLedgerIfExists } from '../src/state-ledger.ts'
32
32
 
33
33
  export interface ArtifactEntry {
34
- source: 'async-run' | 'cwd-file'
34
+ source: 'async-run' | 'cwd-file' | 'turn-handoff'
35
35
  id: string
36
36
  title: string
37
37
  path: string
@@ -56,6 +56,8 @@ export interface ArtifactReadView {
56
56
 
57
57
  export interface ListArtifactsOptions {
58
58
  stateRoot: string
59
+ /** Host-owned handoff storage can differ from the existing async state. */
60
+ handoffRoot?: string
59
61
  cwd?: string
60
62
  limit?: number
61
63
  /** Zero-based nonnegative integer; values outside the eligible set return an empty page with the known total. */
@@ -266,6 +268,28 @@ async function listCwdArtifacts(cwd: string): Promise<ArtifactEntry[]> {
266
268
  return entries
267
269
  }
268
270
 
271
+ /** Durable handoff outputs live outside async/, including honest failure notes. */
272
+ async function listHandoffArtifacts(root: string): Promise<ArtifactEntry[]> {
273
+ const dir = join(root, 'gotry-state', 'turn-handoffs')
274
+ const names = await readdir(dir).catch(() => [] as string[])
275
+ const entries: ArtifactEntry[] = []
276
+ for (const name of names) {
277
+ if (!name.endsWith('.json')) continue
278
+ try {
279
+ const ticket = JSON.parse(await readFile(join(dir, name), 'utf8'))
280
+ if (ticket.schema !== 'gotry_turn_handoff.v1' || name !== `${ticket.id}.json`
281
+ || !['settled', 'failed'].includes(ticket.status) || ticket.deliverableFile !== `${ticket.id}.deliverable.md`) continue
282
+ const path = await realpath(join(dir, ticket.deliverableFile))
283
+ if (!underRoot(path, root) || hasDeniedSegment(path)) continue
284
+ const info = await stat(path)
285
+ if (!info.isFile()) continue
286
+ entries.push({ source: 'turn-handoff', id: ticket.id, title: String(ticket.objective ?? ticket.id),
287
+ path, status: ticket.status, updated: new Date(info.mtimeMs).toISOString(), bytes: info.size })
288
+ } catch { /* A corrupt or missing output cannot be advertised as a file. */ }
289
+ }
290
+ return entries
291
+ }
292
+
269
293
  export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArtifactsResult> {
270
294
  const limit = clampLimit(opts.limit)
271
295
  const offset = normalizeOffset(opts.offset, 'offset')
@@ -274,6 +298,8 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
274
298
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
275
299
  const canonicalRoot = await realpath(root).catch(() => root)
276
300
  const canonicalCwd = await realpath(cwd).catch(() => cwd)
301
+ const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot)
302
+ const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot)
277
303
 
278
304
  // 1) collect all eligible entries per source, without per-source limits —
279
305
  // pageSize is applied to the merged/deduped/sorted set so older cwd files
@@ -281,6 +307,8 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
281
307
  const collected: ArtifactEntry[] = [
282
308
  ...listRunsFromLedger(canonicalRoot, 'local'),
283
309
  ...(await listDeliverableFiles(canonicalRoot)),
310
+ ...(await listHandoffArtifacts(canonicalRoot)),
311
+ ...(canonicalHandoffRoot !== canonicalRoot ? await listHandoffArtifacts(canonicalHandoffRoot) : []),
284
312
  ...(await listCwdArtifacts(canonicalCwd)),
285
313
  ]
286
314
 
@@ -318,6 +346,7 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
318
346
  offset,
319
347
  roots: [canonicalRoot, canonicalCwd],
320
348
  }
349
+ if (!result.roots.includes(canonicalHandoffRoot)) result.roots.push(canonicalHandoffRoot)
321
350
  if (hasMore) result.nextOffset = start + page.length
322
351
  if (needle !== '') result.search = needle
323
352
  return result
@@ -332,6 +361,7 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
332
361
  */
333
362
  export async function readArtifact(opts: {
334
363
  stateRoot: string
364
+ handoffRoot?: string
335
365
  cwd?: string
336
366
  path: string
337
367
  offset?: number
@@ -341,6 +371,8 @@ export async function readArtifact(opts: {
341
371
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
342
372
  const canonicalRoot = await realpath(root).catch(() => root)
343
373
  const canonicalCwd = await realpath(cwd).catch(() => cwd)
374
+ const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot)
375
+ const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot)
344
376
  const raw = String(opts.path ?? '').trim()
345
377
  if (!raw) return { ok: false, error: 'path 必填(来自 gotry_artifacts_list 的 path,或异步工单 id)' }
346
378
 
@@ -357,10 +389,13 @@ export async function readArtifact(opts: {
357
389
  text = run.deliverable
358
390
  filePath = asyncDeliverablePath(root, raw)
359
391
  } else {
360
- const p = asyncDeliverablePath(root, raw)
392
+ const handoff = /^th-[a-z0-9-]+$/i.test(raw)
393
+ ? [join(handoffRoot, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`),
394
+ join(root, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`)] : []
395
+ const p = handoff.find(path => existsSync(path)) ?? asyncDeliverablePath(root, raw)
361
396
  const canonical = await realpath(p).catch(() => null)
362
397
  if (canonical) {
363
- if (!underRoot(canonical, canonicalRoot) || hasDeniedSegment(canonical)) {
398
+ if (!(underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
364
399
  return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` }
365
400
  }
366
401
  const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase()
@@ -384,7 +419,7 @@ export async function readArtifact(opts: {
384
419
  // 路径(realpath 后的 cwd),而 macOS 上 /var 与 /private/var 这类符号链接会让
385
420
  // canonical 路径不匹配字面 cwd,导致 list→read 串联断裂。边界权威仍是下方
386
421
  // realpath 后的 canonical 复检,这里放宽不改变可读集合。
387
- const inScope = (p: string) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot)) && !hasDeniedSegment(p)
422
+ const inScope = (p: string) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot) || underRoot(p, handoffRoot) || underRoot(p, canonicalHandoffRoot)) && !hasDeniedSegment(p)
388
423
  // Prefer an existing candidate so a cwd miss does not mask a valid state-root file.
389
424
  const allowed = candidates.find(p => inScope(p) && existsSync(p)) ?? candidates.find(inScope)
390
425
  if (!allowed) {
@@ -394,7 +429,7 @@ export async function readArtifact(opts: {
394
429
  if (!canonical) {
395
430
  return { ok: false, error: `文件不存在:${raw}`, hint: '先 gotry_artifacts_list 看在册产物' }
396
431
  }
397
- if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot)) || hasDeniedSegment(canonical)) {
432
+ if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
398
433
  return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` }
399
434
  }
400
435
  const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase()