@danceiny/gotry 0.2.0-rc.29 → 0.2.0-rc.30
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-inner.js +7 -1
- package/cordis.gotry-patch.yml +2 -0
- package/dist/capabilities/artifacts.js +41 -4
- 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/bootstrap-tests.js +5 -0
- package/dist/scripts/issue-271-liveness-tests.js +6 -0
- 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/index.js +24 -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 +2 -1
- package/ts/capabilities/artifacts.ts +40 -5
- package/ts/scripts/turn-handoff-collect.ts +63 -21
- package/ts/src/index.ts +24 -8
- package/ts/src/turn-deadline.ts +111 -18
- package/ts/src/turn-handoff-job.ts +120 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import { LocalJobRegistry } from '@deepseek-ai/dsh-jobs-local';
|
|
7
|
+
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local';
|
|
8
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
9
|
+
import { JobId } from '@deepseek-ai/dsh-jobs';
|
|
10
|
+
import { writeTurnHandoffTicket, writeTicketJson, listTurnHandoffTickets } from '../src/turn-deadline.js';
|
|
11
|
+
import { startTurnHandoffJob } from '../src/turn-handoff-job.js';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { listArtifacts, readArtifact } from '../capabilities/artifacts.js';
|
|
15
|
+
import { apply } from '../src/index.js';
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-native-handoff-'));
|
|
17
|
+
const ctx = new Context();
|
|
18
|
+
const owner = { id: SessionId('owner'), ctx };
|
|
19
|
+
ctx.provide('agents', { get: (id) => id === 'owner' ? owner : undefined });
|
|
20
|
+
const jobs = new LocalJobRegistry(ctx, { maxConcurrentJobsPerOwner: 10 });
|
|
21
|
+
const subprocess = new LocalSubprocessRuntime(ctx);
|
|
22
|
+
const detach = jobs.attachController('test');
|
|
23
|
+
const planner = join(root, 'planner.mjs');
|
|
24
|
+
const descendants = [];
|
|
25
|
+
writeFileSync(planner, `console.error('DIAGNOSTIC, NOT THE DELIVERABLE');console.log('# Finished plan\\nOriginal request: '+process.argv[2])`);
|
|
26
|
+
process.env.GOTRY_HANDOFF_PLANNER_BIN = planner;
|
|
27
|
+
try {
|
|
28
|
+
const ticket = await writeTurnHandoffTicket(root, '东北旅行', { workspaceCwd: root, ownerSessionId: owner.id });
|
|
29
|
+
const running = await startTurnHandoffJob(ctx, ticket, owner, root);
|
|
30
|
+
assert.equal(running.status, 'running');
|
|
31
|
+
assert.ok(running.jobId);
|
|
32
|
+
assert.equal(jobs.list(owner.id)[0].label.includes(ticket.id), true, 'native UI jobs carry the durable ticket identity');
|
|
33
|
+
assert.deepEqual(jobs.list(SessionId('foreign')), [], 'another session cannot see the task');
|
|
34
|
+
const jobId = JobId(running.jobId);
|
|
35
|
+
assert.equal((await jobs.wait(jobId, 10_000, owner.id)).status, 'completed');
|
|
36
|
+
const views = await listTurnHandoffTickets(root);
|
|
37
|
+
assert.equal(views[0].status, 'settled');
|
|
38
|
+
assert.ok(views[0].deliverablePath);
|
|
39
|
+
assert.match(jobs.read(jobId, owner.id).result ?? '', /present/, 'completion tells the owning session to present the output');
|
|
40
|
+
const content = readFileSync(views[0].deliverablePath, 'utf8');
|
|
41
|
+
assert.ok(content.includes('# Finished plan'));
|
|
42
|
+
assert.ok(!content.includes('DIAGNOSTIC'), 'stderr cannot be accepted as the plan');
|
|
43
|
+
const artifacts = await listArtifacts({ stateRoot: root, cwd: root });
|
|
44
|
+
assert.ok(artifacts.artifacts.some(item => item.id === ticket.id), 'handoff deliverable must be discoverable');
|
|
45
|
+
assert.equal((await readArtifact({ stateRoot: root, cwd: root, path: ticket.id })).ok, true);
|
|
46
|
+
const legacyRoot = join(root, 'legacy-root');
|
|
47
|
+
mkdirSync(join(legacyRoot, 'gotry-state', 'async'), { recursive: true });
|
|
48
|
+
writeFileSync(join(legacyRoot, 'gotry-state', 'async', 'legacy.deliverable.md'), 'Legacy plan');
|
|
49
|
+
const mixed = await listArtifacts({ stateRoot: legacyRoot, cwd: root });
|
|
50
|
+
assert.ok(mixed.artifacts.some(item => item.id === 'legacy'), 'existing async outputs stay reachable');
|
|
51
|
+
assert.ok(mixed.artifacts.some(item => item.id === ticket.id), 'workspace handoffs coexist with configured async state');
|
|
52
|
+
assert.equal((await readArtifact({ stateRoot: legacyRoot, cwd: root, path: ticket.id })).ok, true);
|
|
53
|
+
const settled = JSON.parse(readFileSync(join(root, 'gotry-state', 'turn-handoffs', `${ticket.id}.json`), 'utf8'));
|
|
54
|
+
const before = jobs.list(owner.id).length;
|
|
55
|
+
assert.equal((await startTurnHandoffJob(ctx, settled, owner, root)).status, 'settled');
|
|
56
|
+
assert.equal(jobs.list(owner.id).length, before, 'terminal replay must not start more work');
|
|
57
|
+
writeFileSync(planner, `setTimeout(()=>console.log('must not complete'),60000)`);
|
|
58
|
+
const pending = await writeTurnHandoffTicket(root, 'cancel', { workspaceCwd: root });
|
|
59
|
+
const cancellable = await startTurnHandoffJob(ctx, pending, owner, root);
|
|
60
|
+
jobs.kill(JobId(cancellable.jobId), owner.id);
|
|
61
|
+
assert.equal((await jobs.wait(JobId(cancellable.jobId), 10_000, owner.id)).status, 'killed');
|
|
62
|
+
const cancelled = (await listTurnHandoffTickets(root)).find(item => item.id === pending.id);
|
|
63
|
+
assert.equal(cancelled.status, 'failed', 'cancellation must settle the durable ticket');
|
|
64
|
+
assert.match(cancelled.error, /cancelled/);
|
|
65
|
+
// The native API separates direct exit from managed-range quiescence.
|
|
66
|
+
// A cancelled collector can exit while the host still owns a live child.
|
|
67
|
+
for (const mode of ['cancel', 'direct-error', 'cleanup-error']) {
|
|
68
|
+
const actualSpawn = subprocess.spawn;
|
|
69
|
+
let settleDirect;
|
|
70
|
+
let releaseRange;
|
|
71
|
+
let rangeWaited = false;
|
|
72
|
+
let terminationRequested = false;
|
|
73
|
+
const directExited = new Promise((resolve, reject) => {
|
|
74
|
+
settleDirect = () => mode !== 'direct-error' ? resolve({ exitCode: 0, signal: null }) : reject(new Error('synthetic collector direct failure'));
|
|
75
|
+
});
|
|
76
|
+
const rangeExited = new Promise(resolve => { releaseRange = resolve; });
|
|
77
|
+
subprocess.spawn = () => ({
|
|
78
|
+
stdin: undefined, stdout: undefined, stderr: undefined, control: undefined,
|
|
79
|
+
done: directExited, collected: {}, terminate() { terminationRequested = true; }, terminateForHostExit() { },
|
|
80
|
+
async waitForExit() {
|
|
81
|
+
rangeWaited = true;
|
|
82
|
+
if (mode === 'cleanup-error')
|
|
83
|
+
throw new Error('synthetic range observation failure');
|
|
84
|
+
await rangeExited;
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
let heldJobId;
|
|
89
|
+
let heldTicketId;
|
|
90
|
+
try {
|
|
91
|
+
const heldTicket = await writeTurnHandoffTicket(root, `${mode}: direct exit before range quiescence`, { workspaceCwd: root });
|
|
92
|
+
heldTicketId = heldTicket.id;
|
|
93
|
+
const held = await startTurnHandoffJob(ctx, heldTicket, owner, root);
|
|
94
|
+
heldJobId = JobId(held.jobId);
|
|
95
|
+
if (mode === 'cancel')
|
|
96
|
+
jobs.kill(heldJobId, owner.id);
|
|
97
|
+
settleDirect();
|
|
98
|
+
if (mode === 'cleanup-error') {
|
|
99
|
+
assert.equal((await jobs.wait(heldJobId, 10_000, owner.id)).status, 'failed');
|
|
100
|
+
assert.equal(terminationRequested, true, 'cleanup observation failure must still request termination');
|
|
101
|
+
assert.match((await listTurnHandoffTickets(root)).find(item => item.id === heldTicket.id).error, /managed-range cleanup could not be confirmed.*synthetic range observation failure/, 'cleanup failure is explicit, never a stopped-process claim');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const activeStatus = mode === 'cancel' ? 'stopping' : 'running';
|
|
105
|
+
const waitBy = Date.now() + 2000;
|
|
106
|
+
while (!rangeWaited && jobs.list(owner.id).find(job => job.id === heldJobId)?.status === activeStatus && Date.now() < waitBy) {
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
108
|
+
}
|
|
109
|
+
assert.equal(rangeWaited, true, `${mode}: direct exit must join the host-owned process range`);
|
|
110
|
+
assert.equal(jobs.list(owner.id).find(job => job.id === heldJobId)?.status, activeStatus, `${mode}: native outcome cannot settle while the managed range is live`);
|
|
111
|
+
assert.equal((await listTurnHandoffTickets(root)).find(item => item.id === heldTicket.id).status, 'running', `${mode}: durable settlement waits for managed-range quiescence too`);
|
|
112
|
+
assert.equal(terminationRequested, true, `${mode}: cleanup must be initiated`);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
settleDirect();
|
|
116
|
+
releaseRange();
|
|
117
|
+
if (heldJobId)
|
|
118
|
+
await jobs.wait(heldJobId, 10_000, owner.id);
|
|
119
|
+
subprocess.spawn = actualSpawn;
|
|
120
|
+
}
|
|
121
|
+
if (mode === 'direct-error') {
|
|
122
|
+
assert.equal(jobs.list(owner.id).find(job => job.id === heldJobId)?.status, 'failed');
|
|
123
|
+
assert.match((await listTurnHandoffTickets(root)).find(item => item.id === heldTicketId).error, /synthetic collector direct failure/, 'keep the direct diagnostic after cleanup');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
writeFileSync(planner, `setTimeout(()=>console.log('late'),60000)`);
|
|
127
|
+
process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS = '50';
|
|
128
|
+
const timeoutTicket = await writeTurnHandoffTicket(root, 'timeout', { workspaceCwd: root });
|
|
129
|
+
const timeoutJob = await startTurnHandoffJob(ctx, timeoutTicket, owner, root);
|
|
130
|
+
assert.equal((await jobs.wait(JobId(timeoutJob.jobId), 10_000, owner.id)).status, 'failed');
|
|
131
|
+
assert.match((await listTurnHandoffTickets(root)).find(item => item.id === timeoutTicket.id).error, /timed out/);
|
|
132
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
133
|
+
// Match gotry-inner's detached DSH child, inherited pipes and real TERM
|
|
134
|
+
// forwarding bound. Its child ignores TERM, requiring wrapper-owned KILL.
|
|
135
|
+
if (process.platform !== 'win32') {
|
|
136
|
+
const pidFile = join(root, 'descendant.pid');
|
|
137
|
+
const readyFile = join(root, 'descendant.ready');
|
|
138
|
+
const lifecycle = pathToFileURL(join(import.meta.dirname, '..', '..', 'bin', 'gotry-process-liveness.js')).href;
|
|
139
|
+
const childCode = `const fs=require('node:fs');process.on('SIGTERM',()=>{});fs.writeFileSync(${JSON.stringify(readyFile)},'ready');setInterval(()=>{},1000)`;
|
|
140
|
+
writeFileSync(planner, `
|
|
141
|
+
import {writeFileSync} from 'node:fs';
|
|
142
|
+
import {spawnOwnedChild,terminateOwnedChild} from ${JSON.stringify(lifecycle)};
|
|
143
|
+
const {child,groupPid}=spawnOwnedChild(process.execPath,['-e',${JSON.stringify(childCode)}],{stdio:['ignore','inherit','inherit']});
|
|
144
|
+
writeFileSync(${JSON.stringify(pidFile)},String(child.pid));
|
|
145
|
+
let stopping=false;
|
|
146
|
+
process.on('SIGTERM',()=>{if(stopping)return;stopping=true;void terminateOwnedChild({child,groupPid}).then(()=>process.exit(143))});
|
|
147
|
+
setInterval(()=>{},1000);
|
|
148
|
+
`);
|
|
149
|
+
for (const mode of ['timeout', 'cancel']) {
|
|
150
|
+
rmSync(readyFile, { force: true });
|
|
151
|
+
rmSync(pidFile, { force: true });
|
|
152
|
+
if (mode === 'timeout')
|
|
153
|
+
process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS = '1000';
|
|
154
|
+
else
|
|
155
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
156
|
+
const nestedTicket = await writeTurnHandoffTicket(root, `nested ${mode}`, { workspaceCwd: root });
|
|
157
|
+
const nestedJob = await startTurnHandoffJob(ctx, nestedTicket, owner, root);
|
|
158
|
+
const readyBy = Date.now() + 5000;
|
|
159
|
+
while (!existsSync(readyFile) && Date.now() < readyBy)
|
|
160
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
161
|
+
assert.ok(existsSync(readyFile), 'detached stubborn child actually started');
|
|
162
|
+
const pid = Number(readFileSync(pidFile, 'utf8'));
|
|
163
|
+
descendants.push(pid);
|
|
164
|
+
if (mode === 'cancel')
|
|
165
|
+
jobs.kill(JobId(nestedJob.jobId), owner.id);
|
|
166
|
+
const terminal = await jobs.wait(JobId(nestedJob.jobId), 15_000, owner.id);
|
|
167
|
+
assert.equal(terminal.status, mode === 'cancel' ? 'killed' : 'failed');
|
|
168
|
+
const processRows = execFileSync('ps', ['-eo', 'pid=,ppid=,pgid=,stat='], { encoding: 'utf8', timeout: 2000 })
|
|
169
|
+
.split('\n').filter(line => Number(line.trim().split(/\s+/)[2]) === pid);
|
|
170
|
+
assert.throws(() => process.kill(-pid, 0), { code: 'ESRCH' }, `${mode} cannot report terminal while the detached planner child is alive; group members=${JSON.stringify(processRows)}`);
|
|
171
|
+
const view = (await listTurnHandoffTickets(root)).find(item => item.id === nestedTicket.id);
|
|
172
|
+
assert.equal(view.status, 'failed');
|
|
173
|
+
assert.match(view.error, mode === 'cancel' ? /cancelled/ : /timed out/);
|
|
174
|
+
}
|
|
175
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
176
|
+
}
|
|
177
|
+
// A fresh host can reuse a native counter id. Its new job must never make
|
|
178
|
+
// an older persisted running ticket look active or offer the wrong controls.
|
|
179
|
+
writeFileSync(planner, `setTimeout(()=>console.log('late'),60000)`);
|
|
180
|
+
const freshTicket = await writeTurnHandoffTicket(root, 'new host trip', { workspaceCwd: root });
|
|
181
|
+
const fresh = await startTurnHandoffJob(ctx, freshTicket, owner, root);
|
|
182
|
+
const stale = await writeTurnHandoffTicket(root, 'old interrupted trip', { workspaceCwd: root });
|
|
183
|
+
await writeTicketJson(root, { ...stale, status: 'running', jobId: fresh.jobId });
|
|
184
|
+
let listTool;
|
|
185
|
+
const queryCtx = {
|
|
186
|
+
tools: { register(tool) { if (tool.name === 'gotry_turn_handoff_list')
|
|
187
|
+
listTool = tool; } },
|
|
188
|
+
systemPrompt: { variable() { } }, on() { return () => { }; },
|
|
189
|
+
get: (name) => name === 'jobs' ? jobs : undefined,
|
|
190
|
+
};
|
|
191
|
+
apply(queryCtx, { stateRoot: root, timeoutMs: 1000, hbcliBin: '', sessionAccess: 'off' });
|
|
192
|
+
const oldQuery = await listTool.execute({ ticketId: stale.id }, { agent: owner });
|
|
193
|
+
assert.match(oldQuery.summary, /已中断/, 'reused native id does not identify another saved ticket');
|
|
194
|
+
const newQuery = await listTool.execute({ ticketId: fresh.id }, { agent: owner });
|
|
195
|
+
assert.match(newQuery.summary, /后台任务/, 'exact current ticket still has live controls');
|
|
196
|
+
jobs.kill(JobId(fresh.jobId), owner.id);
|
|
197
|
+
await jobs.wait(JobId(fresh.jobId), 15_000, owner.id);
|
|
198
|
+
const unhosted = new Context();
|
|
199
|
+
const waiting = await writeTurnHandoffTicket(root, 'no execution services');
|
|
200
|
+
assert.equal((await startTurnHandoffJob(unhosted, waiting, owner, root)).status, 'open');
|
|
201
|
+
assert.equal((await listTurnHandoffTickets(root)).find(item => item.id === waiting.id).jobId, undefined);
|
|
202
|
+
await unhosted.fiber.dispose();
|
|
203
|
+
// A missing controller creates no execution resource and no running claim.
|
|
204
|
+
detach();
|
|
205
|
+
const queued = await writeTurnHandoffTicket(root, 'queued');
|
|
206
|
+
const rejected = await startTurnHandoffJob(ctx, queued, owner, root);
|
|
207
|
+
assert.equal(rejected.status, 'failed');
|
|
208
|
+
assert.ok(rejected.error);
|
|
209
|
+
console.log('native handoff job tests: OK (real registry/process, ownership, managed-range quiescence, settlement, artifacts, idempotence, cancellation, controller rejection)');
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_BIN;
|
|
213
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
214
|
+
for (const pid of descendants) {
|
|
215
|
+
try {
|
|
216
|
+
process.kill(-pid, 'SIGKILL');
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
}
|
|
220
|
+
await ctx.fiber.dispose();
|
|
221
|
+
rmSync(root, { recursive: true, force: true });
|
|
222
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/** Isolated DSH Web handoff journey. Synthetic model/planner; real host, jobs,
|
|
2
|
+
* processes, composer, completion notice, presentation and artifact tools.
|
|
3
|
+
* Never a live travel-research or published-package acceptance receipt. */
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
|
+
import puppeteer from 'puppeteer-core';
|
|
11
|
+
const ROOT = join(import.meta.dirname, '..', '..');
|
|
12
|
+
const sourceSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim();
|
|
13
|
+
const sourceDirty = execFileSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' }).trim() !== '';
|
|
14
|
+
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
15
|
+
const evidence = process.env.GOTRY_HANDOFF_WEB_E2E_OUT ?? mkdtempSync(join(tmpdir(), 'gotry-handoff-web-evidence-'));
|
|
16
|
+
const isolated = mkdtempSync(join(tmpdir(), 'gotry-handoff-web-'));
|
|
17
|
+
const product = join(isolated, 'product');
|
|
18
|
+
const hostCwd = join(isolated, 'host');
|
|
19
|
+
const workspace = join(isolated, 'workspace');
|
|
20
|
+
const stateRoot = join(isolated, 'handoff-state');
|
|
21
|
+
const home = join(isolated, 'home');
|
|
22
|
+
const dshHome = join(isolated, 'dsh');
|
|
23
|
+
for (const dir of [evidence, product, hostCwd, workspace, stateRoot, home, dshHome])
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
const planner = join(isolated, 'planner.mjs');
|
|
26
|
+
writeFileSync(planner, `console.error('DIAGNOSTIC_NOT_A_PLAN');setTimeout(()=>console.log('# Completed synthetic plan\\nWorkspace: '+process.cwd()+'\\n'+process.argv[2]),15000)`);
|
|
27
|
+
const bodies = [];
|
|
28
|
+
const served = [];
|
|
29
|
+
let phase = 0;
|
|
30
|
+
let serverLog = '';
|
|
31
|
+
let notified = false;
|
|
32
|
+
let relayError;
|
|
33
|
+
let browser;
|
|
34
|
+
let page;
|
|
35
|
+
let child;
|
|
36
|
+
const pageErrors = [];
|
|
37
|
+
const sse = (data) => `data: ${JSON.stringify(data)}\n\n`;
|
|
38
|
+
function response(name, input = {}, text = '') {
|
|
39
|
+
const block = name ? { type: 'tool_use', id: `handoff-${phase}`, name, input } : { type: 'text', text };
|
|
40
|
+
return sse({ type: 'message_start', message: { usage: { input_tokens: 10 } } })
|
|
41
|
+
+ sse({ type: 'content_block_start', index: 0, content_block: block })
|
|
42
|
+
+ sse({ type: 'content_block_stop', index: 0 })
|
|
43
|
+
+ sse({ type: 'message_delta', delta: { stop_reason: name ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 10 } })
|
|
44
|
+
+ sse({ type: 'message_stop' }) + 'data: [DONE]\n\n';
|
|
45
|
+
}
|
|
46
|
+
const ticketDir = join(stateRoot, 'gotry-state', 'turn-handoffs');
|
|
47
|
+
function ticket() {
|
|
48
|
+
const names = existsSync(ticketDir) ? readdirSync(ticketDir).filter(name => name.endsWith('.json')) : [];
|
|
49
|
+
assert.equal(names.length, 1, 'one durable ticket');
|
|
50
|
+
return JSON.parse(readFileSync(join(ticketDir, names[0]), 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
const relay = createServer((req, res) => {
|
|
53
|
+
if (req.method !== 'POST' || !req.url?.endsWith('/messages')) {
|
|
54
|
+
res.writeHead(404).end();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const chunks = [];
|
|
58
|
+
req.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
try {
|
|
61
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
62
|
+
bodies.push(body);
|
|
63
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
64
|
+
const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
|
|
65
|
+
const messages = JSON.stringify(body.messages ?? []);
|
|
66
|
+
const isPlanner = names.includes('write') || names.includes('present') || names.includes('job_output');
|
|
67
|
+
if (!isPlanner) {
|
|
68
|
+
res.end(response(undefined, {}, 'Synthetic title'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let name;
|
|
72
|
+
let input = {};
|
|
73
|
+
let final = '';
|
|
74
|
+
let delay = 0;
|
|
75
|
+
if (phase === 0) {
|
|
76
|
+
name = 'write';
|
|
77
|
+
input = { file_path: 'exploration.md', content: '# Synthetic exploration\nWork hours 9-18 CST; original request retained.' };
|
|
78
|
+
// Cross the foreground deadline deliberately, while giving the later
|
|
79
|
+
// completion/query turn enough time for several native tool round trips.
|
|
80
|
+
delay = 5500;
|
|
81
|
+
}
|
|
82
|
+
else if (phase === 1) {
|
|
83
|
+
name = 'present';
|
|
84
|
+
input = { files: [{ path: join(workspace, 'exploration.md'), description: '探索稿' }] };
|
|
85
|
+
}
|
|
86
|
+
else if (phase === 2) {
|
|
87
|
+
final = '探索稿已保存,后台任务已启动。';
|
|
88
|
+
}
|
|
89
|
+
else if (phase === 3) {
|
|
90
|
+
assert.match(messages, /finished.*completed/, 'native completion notice wakes the owning session');
|
|
91
|
+
notified = true;
|
|
92
|
+
name = 'job_output';
|
|
93
|
+
input = { job_id: ticket().jobId, wait: false };
|
|
94
|
+
}
|
|
95
|
+
else if (phase === 4) {
|
|
96
|
+
assert.ok(messages.includes('Planning completed:'), 'native job result reaches the model');
|
|
97
|
+
name = 'present';
|
|
98
|
+
input = { files: [{ path: join(ticketDir, ticket().deliverableFile), description: '最终交付物' }] };
|
|
99
|
+
}
|
|
100
|
+
else if (phase === 5) {
|
|
101
|
+
name = 'gotry_artifacts_list';
|
|
102
|
+
input = { limit: 20 };
|
|
103
|
+
}
|
|
104
|
+
else if (phase === 6) {
|
|
105
|
+
assert.ok(messages.includes(ticket().id), 'artifact list includes the same ticket');
|
|
106
|
+
name = 'gotry_artifacts_read';
|
|
107
|
+
input = { path: ticket().id };
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
final = '最终规划已完成并展示,可按工单重新读取。';
|
|
111
|
+
}
|
|
112
|
+
if (name) {
|
|
113
|
+
assert.ok(names.includes(name), `${name} remains available in phase ${phase}: ${names}`);
|
|
114
|
+
served.push(name);
|
|
115
|
+
}
|
|
116
|
+
phase++;
|
|
117
|
+
setTimeout(() => res.end(response(name, input, final)), delay);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
relayError = String(error);
|
|
121
|
+
res.end(response(undefined, {}, 'RELAY_TEST_FAILURE'));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
assert.ok(existsSync(CHROME), 'Chrome is required for the browser receipt');
|
|
127
|
+
assert.ok(existsSync(join(ROOT, 'dist/src/turn-handoff-job.js')), 'build dist before the browser journey');
|
|
128
|
+
// The real source launcher normally uses ts/dsh-runtime as host cwd. Run the
|
|
129
|
+
// exact built package in a private layout instead, so even legacy state reads
|
|
130
|
+
// and process-guard logs cannot touch the founder's runtime directory.
|
|
131
|
+
const packed = JSON.parse(execFileSync('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', isolated], { cwd: ROOT, encoding: 'utf8' }))[0];
|
|
132
|
+
execFileSync('tar', ['xzf', join(isolated, packed.filename), '--strip-components=1', '-C', product]);
|
|
133
|
+
symlinkSync(join(ROOT, 'node_modules'), join(product, 'node_modules'), 'dir');
|
|
134
|
+
await new Promise(resolve => relay.listen(0, '127.0.0.1', resolve));
|
|
135
|
+
const port = relay.address().port;
|
|
136
|
+
child = spawn(process.execPath, [join(product, 'bin/gotry-inner.js'), 'web', '--no-open', '--port', '0'], {
|
|
137
|
+
cwd: hostCwd,
|
|
138
|
+
env: { ...process.env, HOME: home, DSH_HOME: dshHome, GOTRY_SETUP_SKIP: '1', GOTRY_ONBOARDING_SKIP: '1',
|
|
139
|
+
GOTRY_SESSION_LIVE: '0', GOTRY_HBCLI_LIVE: '0', GOTRY_HOTELBYTE_SKILLS_LIVE: '0',
|
|
140
|
+
LLM_API_KEY: 'synthetic-only', LLM_BASE_URL: `http://127.0.0.1:${port}/v1`, LLM_MODEL: 'synthetic-handoff-web',
|
|
141
|
+
GOTRY_LLM_MODEL: 'synthetic-handoff-web', DEEPSEEK_API_KEY: 'synthetic-only', DEEPSEEK_BASE_URL: `http://127.0.0.1:${port}/v1`,
|
|
142
|
+
GOTRY_TURN_HANDOFF_ROOT: stateRoot, GOTRY_HANDOFF_PLANNER_BIN: planner,
|
|
143
|
+
GOTRY_TURN_DEADLINE_SOFT_MS: '2000', GOTRY_TURN_DEADLINE_HARD_MS: '5000' },
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
});
|
|
146
|
+
const url = await new Promise((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => reject(new Error(`Web startup timed out: ${serverLog.slice(-4000)}`)), 30000);
|
|
148
|
+
const collect = (chunk) => {
|
|
149
|
+
serverLog += chunk.toString();
|
|
150
|
+
const match = serverLog.match(/dsh web: (http:\/\/127\.0\.0\.1:\d+\/\?token=[^\s)]+)/);
|
|
151
|
+
if (match) {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
resolve(match[1]);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
child.stdout.on('data', collect);
|
|
157
|
+
child.stderr.on('data', collect);
|
|
158
|
+
child.once('error', reject);
|
|
159
|
+
});
|
|
160
|
+
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(isolated, 'chrome'), args: ['--no-sandbox', '--disable-gpu'] });
|
|
161
|
+
page = await browser.newPage();
|
|
162
|
+
page.setDefaultTimeout(30000);
|
|
163
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
164
|
+
await page.goto(url, { waitUntil: 'networkidle2' });
|
|
165
|
+
const dismiss = async () => {
|
|
166
|
+
await page.waitForFunction(() => [...document.querySelectorAll('button')].some(node => ['继续', 'Continue'].includes(node.textContent?.trim() ?? '')));
|
|
167
|
+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => ['继续', 'Continue'].includes(node.textContent?.trim() ?? ''))?.click());
|
|
168
|
+
};
|
|
169
|
+
await dismiss();
|
|
170
|
+
const registered = await page.evaluate(async (path) => {
|
|
171
|
+
const response = await fetch('/api/workspace/create', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace/create', payload: { args: { request: { path } } } }) });
|
|
173
|
+
return response.json();
|
|
174
|
+
}, workspace);
|
|
175
|
+
assert.equal(registered.result?.ok, true, 'register the isolated workspace through public RPC');
|
|
176
|
+
await page.reload({ waitUntil: 'networkidle2' });
|
|
177
|
+
await page.waitForFunction(() => [...document.querySelectorAll('button')].some(node => /Default workspace|默认工作区/.test(node.textContent ?? '')));
|
|
178
|
+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => /Default workspace|默认工作区/.test(node.textContent ?? ''))?.click());
|
|
179
|
+
await page.waitForFunction((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].some(node => node.textContent?.trim() === title), {}, basename(workspace));
|
|
180
|
+
await page.evaluate((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].find(node => node.textContent?.trim() === title)?.click(), basename(workspace));
|
|
181
|
+
await page.waitForSelector('[contenteditable="true"]');
|
|
182
|
+
await page.click('[contenteditable="true"]');
|
|
183
|
+
await page.keyboard.type('12月初阿联酋国庆,借5天IRW远程办公和请假回国十几天,去东北长春泡澡,女朋友从南京过去,请探索具体规划。工作时间9-18 CST。');
|
|
184
|
+
await page.click('button[aria-label="发送消息"], button[aria-label="Send message"]');
|
|
185
|
+
await page.waitForFunction(() => /探索稿已保存|RELAY_TEST_FAILURE/.test(document.body.innerText), { timeout: 30000 });
|
|
186
|
+
assert.equal(relayError, undefined);
|
|
187
|
+
assert.ok(existsSync(join(workspace, 'exploration.md')), 'deadline draft really exists in the original workspace');
|
|
188
|
+
const running = ticket();
|
|
189
|
+
assert.equal(running.status, 'running');
|
|
190
|
+
assert.ok(running.jobId);
|
|
191
|
+
await page.screenshot({ path: join(evidence, 'running.png'), fullPage: true });
|
|
192
|
+
await page.waitForFunction(() => /最终规划已完成并展示|RELAY_TEST_FAILURE/.test(document.body.innerText), { timeout: 60000 });
|
|
193
|
+
assert.equal(relayError, undefined);
|
|
194
|
+
const settled = ticket();
|
|
195
|
+
assert.equal(settled.status, 'settled');
|
|
196
|
+
const outputPath = join(ticketDir, settled.deliverableFile);
|
|
197
|
+
const output = readFileSync(outputPath, 'utf8');
|
|
198
|
+
assert.ok(output.includes('Completed synthetic plan') && output.includes(workspace));
|
|
199
|
+
assert.ok(output.includes('9-18 CST'), 'prior constraints travel to the worker');
|
|
200
|
+
assert.ok(!output.includes('DIAGNOSTIC_NOT_A_PLAN'));
|
|
201
|
+
assert.ok(notified);
|
|
202
|
+
assert.deepEqual(pageErrors, []);
|
|
203
|
+
const fileCards = await page.evaluate(() => [...document.querySelectorAll('button')].map(node => ({ text: node.textContent, label: node.getAttribute('aria-label'), title: node.getAttribute('title') })));
|
|
204
|
+
const openLabel = await page.evaluate((path) => {
|
|
205
|
+
const button = [...document.querySelectorAll('button')].find(node => [node.getAttribute('title'), node.getAttribute('aria-label'), node.textContent].some(text => text?.includes(path) || text?.includes(path.split('/').at(-1))));
|
|
206
|
+
if (!button)
|
|
207
|
+
return false;
|
|
208
|
+
button.click();
|
|
209
|
+
return true;
|
|
210
|
+
}, outputPath);
|
|
211
|
+
assert.ok(openLabel, `native file card must offer the final output: ${JSON.stringify(fileCards).slice(-4000)}`);
|
|
212
|
+
await page.waitForFunction(() => document.body.innerText.includes('Completed synthetic plan'), { timeout: 30000 });
|
|
213
|
+
await page.screenshot({ path: join(evidence, 'completed-preview.png'), fullPage: true });
|
|
214
|
+
writeFileSync(join(evidence, 'receipt.json'), JSON.stringify({ passed: true, evidence: 'synthetic_fixture',
|
|
215
|
+
runtime: 'source-built DSH Web', layout: 'isolated tarball with repository dependencies', sourceSha, sourceDirty, nodeVersion: process.version, served, notified, ticketId: settled.id, jobId: settled.jobId,
|
|
216
|
+
draftWritten: true, finalPresented: true, previewOpened: true, status: settled.status, pageErrors }, null, 2));
|
|
217
|
+
console.log(`handoff web E2E: OK; synthetic receipt: ${evidence}`);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (page) {
|
|
221
|
+
await page.screenshot({ path: join(evidence, 'failure.png'), fullPage: true }).catch(() => { });
|
|
222
|
+
writeFileSync(join(evidence, 'failure-dom.txt'), await page.evaluate(() => document.body.innerText + '\n' + JSON.stringify([...document.querySelectorAll('button')].map(node => ({ text: node.textContent, label: node.getAttribute('aria-label'), title: node.getAttribute('title') })))).catch(() => ''));
|
|
223
|
+
}
|
|
224
|
+
writeFileSync(join(evidence, 'failure.json'), JSON.stringify({ error: String(error), phase, served, pageErrors,
|
|
225
|
+
serverLog: serverLog.replace(/token=[^\s)]+/g, 'token=[redacted]').slice(-5000) }, null, 2));
|
|
226
|
+
writeFileSync(join(evidence, 'requests.json'), JSON.stringify(bodies, null, 2));
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
await browser?.close();
|
|
231
|
+
if (child && child.exitCode === null) {
|
|
232
|
+
const closed = new Promise(resolve => child.once('close', () => resolve()));
|
|
233
|
+
child.kill('SIGTERM');
|
|
234
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
235
|
+
await closed;
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
await new Promise(resolve => relay.close(() => resolve()));
|
|
239
|
+
rmSync(isolated, { recursive: true, force: true });
|
|
240
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -48,6 +48,7 @@ import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain
|
|
|
48
48
|
import { hasRecognizedAvailableSeat } from '../capabilities/session/adapters/rail-12306.js';
|
|
49
49
|
import { gateArtifact } from './artifact-gate.js';
|
|
50
50
|
import { installTurnDeadline, listTurnHandoffTickets } from './turn-deadline.js';
|
|
51
|
+
import { startTurnHandoffJob } from './turn-handoff-job.js';
|
|
51
52
|
import { noteChannelVerdict, recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.js';
|
|
52
53
|
import { routingAdvice, renderRoutingCard, toolRoutingHeadline, persistedDownChannels } from '../capabilities/channel-registry.js';
|
|
53
54
|
import { registerBenchmarkEnvironmentBridge } from './benchmark-environment-bridge.js';
|
|
@@ -433,7 +434,11 @@ export function apply(ctx, config, seams = {}) {
|
|
|
433
434
|
? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
|
|
434
435
|
: rawBenchmarkEnvironmentConfigPath.trim()
|
|
435
436
|
? { fixedPolicy: benchmarkPinnedPolicy() }
|
|
436
|
-
: { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.'
|
|
437
|
+
: { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.',
|
|
438
|
+
// A one-shot headless process exits after its final response and cannot
|
|
439
|
+
// own ongoing work. Keep that ticket queued for explicit collection.
|
|
440
|
+
startHandoff: (ticket, agent, root) => typeof ctx.get === 'function' && ctx.get('connection')
|
|
441
|
+
? startTurnHandoffJob(ctx, ticket, agent, root) : Promise.resolve(ticket) });
|
|
437
442
|
if (rawBenchmarkEnvironmentConfigPath.trim()) {
|
|
438
443
|
// Benchmark mode is a deliberately minimal kernel: only the model
|
|
439
444
|
// override and the environment bridge are installed besides the pinned
|
|
@@ -987,8 +992,8 @@ export function apply(ctx, config, seams = {}) {
|
|
|
987
992
|
registerGuarded(defineTool({
|
|
988
993
|
name: 'gotry_turn_handoff_list',
|
|
989
994
|
description: 'List background deep-planning handoff tickets (read-only). Call this whenever the user asks about '
|
|
990
|
-
+ 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open =
|
|
991
|
-
+ '
|
|
995
|
+
+ 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open = queued, worker not started; '
|
|
996
|
+
+ 'running = started with a native job id (use job_list for live status); settled = deliverable ready (excerpt included, full text in the '
|
|
992
997
|
+ 'ticket\'s .deliverable.md), failed = honest failure note. Never fabricate a deliverable that is not here.',
|
|
993
998
|
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层(同 session 刀法)
|
|
994
999
|
parameters: {
|
|
@@ -998,9 +1003,10 @@ export function apply(ctx, config, seams = {}) {
|
|
|
998
1003
|
schema: { type: 'json' },
|
|
999
1004
|
render: (_args, value) => [{ type: 'text', text: String(value.summary ?? '') }],
|
|
1000
1005
|
},
|
|
1001
|
-
async execute(args,
|
|
1006
|
+
async execute(args, exec) {
|
|
1002
1007
|
const q = interpretArgs(args);
|
|
1003
|
-
const
|
|
1008
|
+
const configuredRoot = process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.';
|
|
1009
|
+
const root = configuredRoot === '.' ? sessionCwd(exec) ?? '.' : configuredRoot;
|
|
1004
1010
|
const all = await listTurnHandoffTickets(root);
|
|
1005
1011
|
const tickets = (q.ticketId ? all.filter(t => t.id === q.ticketId) : all).slice(0, 10);
|
|
1006
1012
|
if (tickets.length === 0) {
|
|
@@ -1014,10 +1020,19 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1014
1020
|
const lines = tickets.map(t => {
|
|
1015
1021
|
const head = `[${t.status}] ${t.id} ${t.objective.slice(0, 40)}`;
|
|
1016
1022
|
if (t.status === 'open')
|
|
1017
|
-
return `${head}
|
|
1023
|
+
return `${head}——待启动,后台尚未运行`;
|
|
1024
|
+
if (t.status === 'running') {
|
|
1025
|
+
const owner = exec.agent;
|
|
1026
|
+
// Native counters restart with the host. A recycled id alone is not
|
|
1027
|
+
// the durable ticket's identity and must not offer another trip's job.
|
|
1028
|
+
const live = owner && ctx.get('jobs')?.list(owner.id).some(job => job.id === t.jobId
|
|
1029
|
+
&& job.owner === owner.id && job.kind === 'gotry-planning' && job.label.startsWith(`${t.id}: `)
|
|
1030
|
+
&& (job.status === 'running' || job.status === 'stopping'));
|
|
1031
|
+
return `${head}——${live ? `后台任务 ${t.jobId},预计 ${t.etaLabel}` : '运行记录已中断,当前没有活跃后台任务'} `;
|
|
1032
|
+
}
|
|
1018
1033
|
if (t.status === 'failed')
|
|
1019
1034
|
return `${head}——失败:${t.error ?? '未知原因'}(请重新发起规划)`;
|
|
1020
|
-
return `${head}
|
|
1035
|
+
return `${head}——${t.deliverablePath ? `已交付:${t.deliverablePath}` : '交付文件缺失'},摘要:${(t.deliverableExcerpt ?? '').slice(0, 120).replace(/\n/g, ' ')}`;
|
|
1021
1036
|
});
|
|
1022
1037
|
return {
|
|
1023
1038
|
ok: true, count: tickets.length, tickets,
|
|
@@ -2355,6 +2370,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
2355
2370
|
try {
|
|
2356
2371
|
r = await listArtifacts({
|
|
2357
2372
|
stateRoot: config.stateRoot ?? '.',
|
|
2373
|
+
handoffRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? ((config.stateRoot ?? '.') === '.' ? sessionCwd(exec) ?? '.' : config.stateRoot),
|
|
2358
2374
|
cwd: sessionCwd(exec),
|
|
2359
2375
|
limit: q.limit,
|
|
2360
2376
|
offset: q.offset,
|
|
@@ -2448,7 +2464,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
2448
2464
|
throw new Error('gotry_artifacts_read requires path');
|
|
2449
2465
|
if (!q.path)
|
|
2450
2466
|
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 });
|
|
2467
|
+
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
2468
|
if (!r.ok)
|
|
2453
2469
|
return JSON.parse(JSON.stringify(r));
|
|
2454
2470
|
// 身份/来源展示(issue #285 第 1 条「显示身份/来源」):行号视图保留 read 卡的
|