@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/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 =
|
|
991
|
-
+ '
|
|
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,
|
|
1012
|
+
async execute(args, exec) {
|
|
1002
1013
|
const q = interpretArgs(args);
|
|
1003
|
-
const
|
|
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}
|
|
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}
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
259
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Stai Travel Bridge",
|
|
4
|
-
"version": "0.2.0.
|
|
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.
|
|
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.
|
|
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
|
|
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()
|