termdock 1.4.308 → 1.4.309
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/dist/server/agent/collaborationCli.js +21 -5
- package/dist/server/agent/collaborationStore.js +5 -0
- package/dist/server/agent/integrationCli.js +37 -2
- package/dist/server/agent/integrationServer.js +15 -2
- package/dist/server/agent/integrationSessions.js +226 -18
- package/dist/server/agent/integrationStore.js +1 -1
- package/dist/server/agent/sessionLifecycle.js +81 -0
- package/dist/server/agent/sessionResumeConfiguration.js +32 -0
- package/dist/server/routes/terminal.js +75 -1
- package/docs/integrations.md +138 -3
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -68,6 +68,20 @@ export const COLLAB_HELP = `td collab — durable messages; no agent-specific ho
|
|
|
68
68
|
消息历史只覆盖仍保留记录;不存在的 after-id 返回 MESSAGE_NOT_FOUND,不能据此声称历史完整。
|
|
69
69
|
session create --group <id> --launch-profile <id> --cwd <absolute-path> --idempotency-key <key>
|
|
70
70
|
session get <id> | session restore <id> --idempotency-key <key>
|
|
71
|
+
session release <id> --generation <session-get-generation> --idempotency-key <key>
|
|
72
|
+
权限 session.release;仅原 Agent 已退出、精确空 shell、无 pending 消息时释放实际运行承载。
|
|
73
|
+
released: runtime_present=false、terminal_binding=null;保留逻辑 ID/原生 UUID/原消息,restore 后可继续 reply。
|
|
74
|
+
released 上 send/reply 返回 SESSION_RELEASED,不入队、不隐式恢复;业务先持久接收,再明确 restore。
|
|
75
|
+
释放/恢复最低 CLI/实际服务 1.4.309;检查 session_runtime_release/stable_session_identity/session_lifecycle_generation。
|
|
76
|
+
generation 每次实际 restore/release/管理员兼容确认递增;旧 generation 拒绝,运行中精确 restore 不递增。
|
|
77
|
+
重用同幂等键回读原操作结果,不能用于新一轮恢复;用 session get 查询当前状态。
|
|
78
|
+
旧配置须管理员 td integration resume-configuration <id> 查看,再 confirm-resume 显式确认当前指纹。
|
|
79
|
+
状态 starting/restoring/binding_pending/ready/failed/releasing/released;runtime_present=null 表示无法确认。
|
|
80
|
+
失败码:INVALID_SESSION_GENERATION、SESSION_GENERATION_MISMATCH、SESSION_RELEASE_UNSUPPORTED;
|
|
81
|
+
SESSION_PENDING_MESSAGES/SESSION_STILL_RUNNING/SESSION_OPERATION_IN_PROGRESS 表示应先处理原现场;
|
|
82
|
+
SESSION_NATIVE_OPERATION_IN_PROGRESS、SESSION_SCOPE_CONFLICT、SESSION_IDENTITY_MISMATCH 拒绝并发或身份/归属冲突;
|
|
83
|
+
SESSION_RELEASE_FAILED、SESSION_STORAGE_FULL 保留原记录,不表示回收成功;
|
|
84
|
+
RESUME_CONFIGURATION_CHANGED/RESUME_CONFIGURATION_UNCONFIRMED 拒绝静默替换配置;OPERATION_OUTCOME_UNCONFIRMED 保留记录待核对。
|
|
71
85
|
task respond <task-id> --attempt <attempt-id> --to-event <comment-event-id> --content <答复>
|
|
72
86
|
凭据通过 TERMDOCK_INTEGRATION_CREDENTIAL_FILE 注入,不通过命令行传 token。
|
|
73
87
|
消息接入最低 CLI/实际服务均为 1.4.308;capabilities 回读双方版本与 integration_message_send/reply/history。
|
|
@@ -172,7 +186,7 @@ function roleLine(member, role) {
|
|
|
172
186
|
return role ? `- ${label}:${role}` : `- ${label}(未设置)`;
|
|
173
187
|
}
|
|
174
188
|
const BOOLEAN_OPTIONS = new Set(['json', 'jsonl', 'text', 'follow', 'stdin', 'receipt-only', 'confirm', 'raw', 'help', 'no-rules', 'managed', 'shared-directory', 'integration']);
|
|
175
|
-
const VALUE_OPTIONS = new Set(['purpose', 'principal', 'source', 'external-actor', 'external-message-id', 'launch-profile', 'to-event', 'decision', 'work-type', 'summary', 'verdict', 'reviewers', 'title', 'content', 'constraints', 'acceptance', 'assignee', 'coordinator', 'parent', 'depends-on', 'attempt', 'status', 'artifact', 'options', 'revision', 'evidence', 'session', 'group', 'thread', 'idempotency-key', 'file', 'wait-until', 'timeout', 'expect-reply', 'response-kind', 'metadata', 'task-envelope', 'expires-at', 'since', 'after-id', 'cursor', 'consumer', 'limit', 'from', 'kind', 'name', 'cwd', 'task', 'pane', 'lines', 'if-version', 'origin']);
|
|
189
|
+
const VALUE_OPTIONS = new Set(['generation', 'purpose', 'principal', 'source', 'external-actor', 'external-message-id', 'launch-profile', 'to-event', 'decision', 'work-type', 'summary', 'verdict', 'reviewers', 'title', 'content', 'constraints', 'acceptance', 'assignee', 'coordinator', 'parent', 'depends-on', 'attempt', 'status', 'artifact', 'options', 'revision', 'evidence', 'session', 'group', 'thread', 'idempotency-key', 'file', 'wait-until', 'timeout', 'expect-reply', 'response-kind', 'metadata', 'task-envelope', 'expires-at', 'since', 'after-id', 'cursor', 'consumer', 'limit', 'from', 'kind', 'name', 'cwd', 'task', 'pane', 'lines', 'if-version', 'origin']);
|
|
176
190
|
export function parseCollaborationCommand(argv) {
|
|
177
191
|
const options = {};
|
|
178
192
|
const positional = [];
|
|
@@ -238,12 +252,14 @@ export function parseCollaborationCommand(argv) {
|
|
|
238
252
|
throw new Error('Unexpected integration arguments');
|
|
239
253
|
if (action === 'events' && (!['subscribe', 'ack'].includes(command.operation ?? '') || (command.operation === 'ack' ? !command.target : Boolean(command.target))))
|
|
240
254
|
throw new Error('Usage: events subscribe | events ack <cursor>');
|
|
241
|
-
if (action === 'session' && (!['create', 'get', 'restore'].includes(command.operation ?? '') || (command.operation === 'create' ? Boolean(command.target) : !command.target)))
|
|
242
|
-
throw new Error('Usage: session create|get|restore');
|
|
255
|
+
if (action === 'session' && (!['create', 'get', 'restore', 'release'].includes(command.operation ?? '') || (command.operation === 'create' ? Boolean(command.target) : !command.target)))
|
|
256
|
+
throw new Error('Usage: session create|get|restore|release');
|
|
243
257
|
if (action === 'session' && command.operation !== 'get' && !options['idempotency-key'])
|
|
244
258
|
throw new Error('Session mutation requires an explicit --idempotency-key');
|
|
259
|
+
if (action === 'session' && command.operation === 'release' && (!/^[1-9][0-9]*$/.test(String(options.generation ?? '')) || !Number.isSafeInteger(Number(options.generation))))
|
|
260
|
+
throw new Error('session release requires --generation from session get');
|
|
245
261
|
const integrationFlags = action === 'events' ? command.operation === 'ack' ? ['consumer'] : ['consumer', 'group', 'timeout']
|
|
246
|
-
: command.operation === 'create' ? ['group', 'launch-profile', 'cwd', 'idempotency-key'] : command.operation === 'restore' ? ['idempotency-key'] : [];
|
|
262
|
+
: command.operation === 'create' ? ['group', 'launch-profile', 'cwd', 'idempotency-key'] : command.operation === 'restore' ? ['idempotency-key'] : command.operation === 'release' ? ['idempotency-key', 'generation'] : [];
|
|
247
263
|
for (const flag of Object.keys(options))
|
|
248
264
|
if (!['principal', 'json', 'jsonl', 'text', 'help', ...integrationFlags].includes(flag))
|
|
249
265
|
throw new Error(`--${flag} is not supported by ${action} ${command.operation}`);
|
|
@@ -381,7 +397,7 @@ export function parseCollaborationCommand(argv) {
|
|
|
381
397
|
throw new Error(`Unexpected arguments for ${action}`);
|
|
382
398
|
const allowed = new Set(['json', 'jsonl', 'text', 'help', 'session', 'principal']);
|
|
383
399
|
const byAction = {
|
|
384
|
-
events: ['consumer', 'group', 'timeout'], session: ['group', 'launch-profile', 'cwd', 'idempotency-key'],
|
|
400
|
+
events: ['consumer', 'group', 'timeout'], session: ['group', 'launch-profile', 'cwd', 'idempotency-key', 'generation'],
|
|
385
401
|
task: ['purpose', 'to-event', 'decision', 'source', 'external-actor', 'external-message-id', 'metadata', 'work-type', 'summary', 'integration', 'managed', 'shared-directory', 'reviewers', 'verdict', 'group', 'title', 'content', 'constraints', 'acceptance', 'assignee', 'coordinator', 'parent', 'depends-on', 'attempt', 'status', 'artifact', 'options', 'revision', 'evidence', 'idempotency-key', 'file', 'stdin'],
|
|
386
402
|
rules: ['file', 'stdin', 'if-version'], transport: ['file', 'origin'], group: ['file'], status: [], capabilities: [], rebind: ['pane'], help: [...BOOLEAN_OPTIONS, ...VALUE_OPTIONS],
|
|
387
403
|
send: ['group', 'thread', 'idempotency-key', 'file', 'stdin', 'wait-until', 'timeout', 'expect-reply', 'response-kind', 'metadata', 'expires-at', 'kind'],
|
|
@@ -45,6 +45,8 @@ export class CollaborationStore {
|
|
|
45
45
|
drawId;
|
|
46
46
|
onMessageQueued;
|
|
47
47
|
changeListeners = new Set();
|
|
48
|
+
runtimeGuards = new Set();
|
|
49
|
+
registerRuntimeGuard(guard) { this.runtimeGuards.add(guard); return () => { this.runtimeGuards.delete(guard); }; }
|
|
48
50
|
subscribe(listener) { this.changeListeners.add(listener); return () => { this.changeListeners.delete(listener); }; }
|
|
49
51
|
snapshotMessages() { return structuredClone(this.document.messages); }
|
|
50
52
|
peerActivity = new Map();
|
|
@@ -324,6 +326,9 @@ export class CollaborationStore {
|
|
|
324
326
|
throw new CollaborationError('MESSAGE_TOO_LARGE', `Message exceeds ${COLLAB_LIMITS.message_bytes} UTF-8 bytes`, 413);
|
|
325
327
|
if (!content.trim())
|
|
326
328
|
throw new Error('消息不能为空');
|
|
329
|
+
for (const id of input.toSessionIds)
|
|
330
|
+
for (const guard of this.runtimeGuards)
|
|
331
|
+
guard(id);
|
|
327
332
|
const recipients = Array.from(new Set(input.toSessionIds)).filter((id) => group.sessionIds.includes(id) && id !== input.fromSessionId);
|
|
328
333
|
if (recipients.length === 0)
|
|
329
334
|
throw new Error('没有有效的接收会话');
|
|
@@ -8,6 +8,15 @@ import { COLLAB_HELP, executeCollaborationCommand, duration } from './collaborat
|
|
|
8
8
|
import { getTermdockVersion } from '../utils/version.js';
|
|
9
9
|
export const INTEGRATION_HELP = `td integration — local scoped integration administration
|
|
10
10
|
groups | list
|
|
11
|
+
resume-configuration <TD-session-id> (read current/recorded fingerprints; no mutation)
|
|
12
|
+
confirm-resume <TD-session-id> --generation <N> --fingerprint <sha256>
|
|
13
|
+
Administrator explicitly authorizes the current launcher/plugin recovery semantics.
|
|
14
|
+
This never proves unknown historical file contents or modifies Agent configuration.
|
|
15
|
+
Old records need this confirmation before release; new records capture a fingerprint.
|
|
16
|
+
Later changes fail closed. Grant session.release separately for runtime reclamation.
|
|
17
|
+
Minimum CLI/running service 1.4.309; capabilities.session_resume_configuration=true.
|
|
18
|
+
Errors: RESUME_CONFIGURATION_CHANGED, EXACT_RESUME_UNSUPPORTED,
|
|
19
|
+
SESSION_GENERATION_MISMATCH, SESSION_OPERATION_IN_PROGRESS, LAUNCHER_UNAVAILABLE.
|
|
11
20
|
diagnostics <TD-session-id> (read the last restore check; administrator only)
|
|
12
21
|
create --file <policy.json> --credential-file <private-output.json>
|
|
13
22
|
update --file <policy.json> (same identity/group; preserves the credential)
|
|
@@ -63,6 +72,30 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
|
|
|
63
72
|
return 0;
|
|
64
73
|
}
|
|
65
74
|
const socketPath = integrationSocketPath(port);
|
|
75
|
+
if (action === 'resume-configuration' || action === 'confirm-resume') {
|
|
76
|
+
const id = argv[1];
|
|
77
|
+
if (!id || !/^[a-zA-Z0-9._-]{1,80}$/.test(id))
|
|
78
|
+
throw new Error('A full TD session id is required');
|
|
79
|
+
const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
|
|
80
|
+
if (capabilities.session_resume_configuration !== true)
|
|
81
|
+
throw new CollaborationError('SESSION_RELEASE_UNSUPPORTED', 'CLI and running service must be at least 1.4.309', 409);
|
|
82
|
+
let body;
|
|
83
|
+
if (action === 'confirm-resume') {
|
|
84
|
+
const args = {};
|
|
85
|
+
for (let i = 2; i < argv.length; i += 2) {
|
|
86
|
+
if (!['--generation', '--fingerprint'].includes(argv[i]) || !argv[i + 1] || args[argv[i]])
|
|
87
|
+
throw new Error('confirm-resume requires --generation and --fingerprint');
|
|
88
|
+
args[argv[i]] = argv[i + 1];
|
|
89
|
+
}
|
|
90
|
+
if (!/^[1-9][0-9]*$/.test(args['--generation'] ?? '') || !Number.isSafeInteger(Number(args['--generation'])) || !/^[a-f0-9]{64}$/.test(args['--fingerprint'] ?? ''))
|
|
91
|
+
throw new Error('Invalid generation or fingerprint');
|
|
92
|
+
body = { generation: Number(args['--generation']), fingerprint: args['--fingerprint'] };
|
|
93
|
+
}
|
|
94
|
+
else if (argv.length !== 2)
|
|
95
|
+
throw new Error('resume-configuration requires one TD session id');
|
|
96
|
+
write(JSON.stringify(parsed(await request(socketPath, body ? 'POST' : 'GET', `/admin/sessions/${encodeURIComponent(id)}/resume-configuration`, body, headers(undefined, adminToken)))));
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
66
99
|
if (action === 'diagnostics') {
|
|
67
100
|
if (argv.length !== 2 || !/^[a-zA-Z0-9._-]{1,80}$/.test(argv[1]))
|
|
68
101
|
throw new Error('diagnostics requires one TD session id');
|
|
@@ -161,9 +194,11 @@ export async function runIntegrationCollab(command, port, io) {
|
|
|
161
194
|
return subscribe(socketPath, `/events?${params}`, credential, command.options.timeout ? duration(String(command.options.timeout)) : undefined, io.write);
|
|
162
195
|
}
|
|
163
196
|
if (command.action === 'session') {
|
|
164
|
-
|
|
197
|
+
if (command.operation === 'release' && (capability.session_runtime_release !== true || capability.session_lifecycle_generation !== true || capability.stable_session_identity !== true))
|
|
198
|
+
throw new CollaborationError('SESSION_RELEASE_UNSUPPORTED', 'CLI and running service must support the lifecycle contract (minimum 1.4.309)', 409);
|
|
199
|
+
const route = command.operation === 'create' ? '/sessions' : `/sessions/${encodeURIComponent(command.target)}${['restore', 'release'].includes(command.operation) ? `/${command.operation}` : ''}`;
|
|
165
200
|
const body = command.operation === 'create' ? { group_id: command.options.group, launch_profile: command.options['launch-profile'], cwd: command.options.cwd, idempotency_key: command.options['idempotency-key'] }
|
|
166
|
-
: command.operation === '
|
|
201
|
+
: ['restore', 'release'].includes(command.operation) ? { ...(command.operation === 'release' ? { generation: Number(command.options.generation) } : {}), idempotency_key: command.options['idempotency-key'] } : undefined;
|
|
167
202
|
io.write(JSON.stringify(parsed(await request(socketPath, command.operation === 'get' ? 'GET' : 'POST', route, body, headers(credential)))));
|
|
168
203
|
return 0;
|
|
169
204
|
}
|
|
@@ -34,6 +34,7 @@ export class IntegrationRuntime {
|
|
|
34
34
|
this.options = options;
|
|
35
35
|
this.store = new IntegrationStore(path.join(options.directory, 'integrations.json'));
|
|
36
36
|
this.sessions = new IntegrationSessions(path.join(options.directory, 'integration-sessions.json'), options.sessions);
|
|
37
|
+
this.stops.push(options.messages.registerRuntimeGuard(id => this.sessions.assertRuntimeMessage(id)));
|
|
37
38
|
const changed = () => { try {
|
|
38
39
|
this.sync();
|
|
39
40
|
}
|
|
@@ -70,6 +71,8 @@ export class IntegrationRuntime {
|
|
|
70
71
|
app.get('/admin/groups', run(req => { admin(req); return { groups: options.messages.list().filter(g => !g.federated).map(g => ({ id: g.id, name: g.name })) }; }));
|
|
71
72
|
app.get('/admin/capabilities', run(req => { admin(req); return this.capabilities(); }));
|
|
72
73
|
app.get('/admin/sessions/:id/restore-diagnostics', run(req => { admin(req); return this.sessions.restoreDiagnostics(req.params.id); }));
|
|
74
|
+
app.get('/admin/sessions/:id/resume-configuration', run(async (req) => { admin(req); return this.sessions.resumeConfiguration(req.params.id); }));
|
|
75
|
+
app.post('/admin/sessions/:id/resume-configuration', run(async (req) => { admin(req); return { session: publicIntegrationSession(await this.sessions.confirmResumeConfiguration(req.params.id, req.body?.generation, req.body?.fingerprint)) }; }));
|
|
73
76
|
app.get('/admin/principals', run(req => { admin(req); return { integrations: this.store.policies() }; }));
|
|
74
77
|
app.post('/admin/principals', run(req => { admin(req); this.group(req.body?.groupId); const provisioned = this.store.provision(req.body); try {
|
|
75
78
|
this.sync();
|
|
@@ -120,7 +123,13 @@ export class IntegrationRuntime {
|
|
|
120
123
|
const targets = Array.isArray(raw.toSessionIds) ? raw.toSessionIds : [raw.targetSessionId];
|
|
121
124
|
if (!targets.length || targets.some((id) => typeof id !== 'string'))
|
|
122
125
|
integrationError('INVALID_MESSAGE_TARGET', 'Specify a local group member');
|
|
123
|
-
const
|
|
126
|
+
const descriptors = this.sessions.snapshot().filter(s => s.group_id === p.groupId);
|
|
127
|
+
const recipients = targets.map((id) => scopedId(id, [...group.sessionIds, ...descriptors.map(s => s.session_id)], 'SESSION'));
|
|
128
|
+
for (const id of recipients) {
|
|
129
|
+
this.sessions.assertMessageTarget(p, id);
|
|
130
|
+
if (!group.sessionIds.includes(id))
|
|
131
|
+
integrationError('SESSION_NOT_FOUND', 'Restore the original runtime and membership before sending', 404);
|
|
132
|
+
}
|
|
124
133
|
let thread;
|
|
125
134
|
if (raw.thread_id)
|
|
126
135
|
thread = scopedId(raw.thread_id, options.messages.snapshotMessages().filter(m => m.groupId === p.groupId).map(m => m.threadId), 'THREAD');
|
|
@@ -135,6 +144,8 @@ export class IntegrationRuntime {
|
|
|
135
144
|
const raw = req.body ?? {}, original = this.message(p, raw.messageId);
|
|
136
145
|
const target = original.toPrincipalId === p.id ? original.fromSessionId
|
|
137
146
|
: original.integrationOrigin?.integrationId === p.id ? original.toSessionId : null;
|
|
147
|
+
if (target)
|
|
148
|
+
this.sessions.assertMessageTarget(p, target);
|
|
138
149
|
if (!target || !this.group(p.groupId).sessionIds.includes(target))
|
|
139
150
|
integrationError('NO_REPLY_TARGET', 'Reply requires your own message and its original live group member', 409);
|
|
140
151
|
const messages = options.messages.send({ ...this.messageExtras(raw), groupId: p.groupId, fromSessionId: null,
|
|
@@ -180,6 +191,7 @@ export class IntegrationRuntime {
|
|
|
180
191
|
}));
|
|
181
192
|
app.get('/sessions/:id', run(async (req) => { const p = principal(req); this.authorize(p, 'session.read'); return { session: publicIntegrationSession(await this.sessions.get(p, req.params.id)) }; }));
|
|
182
193
|
app.post('/sessions/:id/restore', run(async (req) => { const p = principal(req); this.authorize(p, 'session.restore'); const session = await this.sessions.restore(p, req.params.id, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
|
|
194
|
+
app.post('/sessions/:id/release', run(async (req) => { const p = principal(req); this.authorize(p, 'session.release'); const session = await this.sessions.release(p, req.params.id, req.body?.generation, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
|
|
183
195
|
app.get('/events', run((req, res) => {
|
|
184
196
|
const p = principal(req);
|
|
185
197
|
this.authorize(p, 'events.read', req.query.group);
|
|
@@ -203,7 +215,8 @@ export class IntegrationRuntime {
|
|
|
203
215
|
integration_principal: true, group_scoped_permissions: true, durable_event_push: true,
|
|
204
216
|
event_ack_semantics: 'durable_received', correlated_task_responses: true, accepted_task_comments: true,
|
|
205
217
|
idempotent_session_create: true, exact_session_restore: true, external_validation: false,
|
|
206
|
-
startup_input_conditions: true,
|
|
218
|
+
startup_input_conditions: true, session_runtime_release: true, stable_session_identity: true, session_lifecycle_generation: true,
|
|
219
|
+
session_resume_configuration: true, session_lifecycle_min_version: '1.4.309',
|
|
207
220
|
integration_policy_update: true,
|
|
208
221
|
session_restore_diagnostics: true,
|
|
209
222
|
native_identity_linux_flock_owner: process.platform === 'linux',
|
|
@@ -2,6 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { atomicJson, integrationError } from './integrationStore.js';
|
|
5
|
+
import { SessionLifecycleCoordinator, assertMessageRuntime, assertReleaseFacts, assertReleaseGeneration } from './sessionLifecycle.js';
|
|
6
|
+
import { resumeConfigurationFingerprint } from './sessionResumeConfiguration.js';
|
|
5
7
|
import { NativeResumeOwnerError } from './nativeResumeOwner.js';
|
|
6
8
|
/** Quote each configured argument as literal shell data, including paths. */
|
|
7
9
|
export function integrationLaunchCommand(record, restore) {
|
|
@@ -50,7 +52,8 @@ export class IntegrationSessions {
|
|
|
50
52
|
doc = { version: 1, sessions: [], requests: {} };
|
|
51
53
|
pending = new Map();
|
|
52
54
|
listeners = new Set();
|
|
53
|
-
|
|
55
|
+
lifecycle = new SessionLifecycleCoordinator();
|
|
56
|
+
mutations = new Map();
|
|
54
57
|
timers = new Set();
|
|
55
58
|
stopObservation;
|
|
56
59
|
stopDeliveryGuard;
|
|
@@ -70,8 +73,12 @@ export class IntegrationSessions {
|
|
|
70
73
|
if (error.code !== 'ENOENT')
|
|
71
74
|
throw error;
|
|
72
75
|
}
|
|
76
|
+
for (const record of this.doc.sessions) {
|
|
77
|
+
record.generation ??= 1;
|
|
78
|
+
record.runtime_present ??= null;
|
|
79
|
+
}
|
|
73
80
|
for (const record of this.doc.sessions)
|
|
74
|
-
if (['starting', 'restoring', 'binding_pending'].includes(record.state) || record.startup_input?.state === 'pending')
|
|
81
|
+
if (['starting', 'restoring', 'binding_pending'].includes(record.state) || record.state === 'releasing' || record.startup_input?.state === 'pending')
|
|
75
82
|
this.scheduleRefresh(record);
|
|
76
83
|
// A pre-restart partial match cannot prove continuity of the live screen.
|
|
77
84
|
for (const record of this.doc.sessions)
|
|
@@ -89,12 +96,17 @@ export class IntegrationSessions {
|
|
|
89
96
|
}
|
|
90
97
|
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
91
98
|
snapshot() { return structuredClone(this.doc.sessions); }
|
|
92
|
-
save() {
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
save() {
|
|
100
|
+
if (Buffer.byteLength(JSON.stringify(this.doc)) > 64 * 1024 * 1024)
|
|
101
|
+
integrationError('SESSION_STORAGE_FULL', 'Recovery descriptor journal is full; no new operation was committed', 503);
|
|
102
|
+
atomicJson(this.file, this.doc);
|
|
103
|
+
for (const listener of this.listeners) {
|
|
104
|
+
try {
|
|
105
|
+
listener();
|
|
106
|
+
}
|
|
107
|
+
catch { /* Durable session state is reconciled on restart. */ }
|
|
95
108
|
}
|
|
96
|
-
|
|
97
|
-
} }
|
|
109
|
+
}
|
|
98
110
|
saveRecord(record) {
|
|
99
111
|
const before = this.doc.sessions;
|
|
100
112
|
this.doc.sessions = [...before.filter(s => s.session_id !== record.session_id), structuredClone(record)];
|
|
@@ -139,7 +151,7 @@ export class IntegrationSessions {
|
|
|
139
151
|
integrationError('INTEGRATION_SESSION_LIMIT', 'Integration session storage is full', 409);
|
|
140
152
|
const now = Date.now(), record = { operation_id: randomUUID(), session_id: randomBytes(6).toString('hex'),
|
|
141
153
|
group_id: principal.groupId, principal_id: principal.id, agent_slug: profile.agentSlug, agent_native_session_id: null,
|
|
142
|
-
state: 'starting', error_code: null, cwd, launch_profile: profile.id, profile: structuredClone(profile),
|
|
154
|
+
state: 'starting', generation: 1, runtime_present: null, error_code: null, cwd, launch_profile: profile.id, profile: structuredClone(profile),
|
|
143
155
|
created_at: now, updated_at: now, deadline: now + this.bindingTimeout, launch_submitted: false };
|
|
144
156
|
record.startup_input = startupInput(record, now);
|
|
145
157
|
this.doc.requests[request.scoped] = { hash: request.hash, sessionId: record.session_id };
|
|
@@ -156,6 +168,21 @@ export class IntegrationSessions {
|
|
|
156
168
|
const promise = (async () => {
|
|
157
169
|
try {
|
|
158
170
|
// Store intent before launching. A failed persistence cannot execute.
|
|
171
|
+
if (this.adapter.resumeConfiguration) {
|
|
172
|
+
try {
|
|
173
|
+
const fingerprint = await resumeConfigurationFingerprint(record, this.adapter);
|
|
174
|
+
if (record.resume_configuration_fingerprint && record.resume_configuration_fingerprint !== fingerprint)
|
|
175
|
+
integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery launcher or plugin semantics changed', 409);
|
|
176
|
+
if (!restoring)
|
|
177
|
+
record.resume_configuration_fingerprint = fingerprint;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
// Existing creation supports plugins without an exact resume path.
|
|
181
|
+
// They remain launchable, but cannot gain a releasable descriptor.
|
|
182
|
+
if (restoring || error.code !== 'EXACT_RESUME_UNSUPPORTED')
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
159
186
|
record.launch_submitted = true;
|
|
160
187
|
this.saveRecord(record);
|
|
161
188
|
const prepared = (binding) => { record.terminal_binding = binding; this.saveRecord(record); };
|
|
@@ -187,27 +214,74 @@ export class IntegrationSessions {
|
|
|
187
214
|
return structuredClone({ session_id: record.session_id, operation_id: record.operation_id,
|
|
188
215
|
state: record.state, error_code: record.error_code, diagnostics: record.restore_diagnostics ?? null });
|
|
189
216
|
}
|
|
190
|
-
|
|
217
|
+
assertRuntimeMessage(id) { assertMessageRuntime(this.doc.sessions.find(s => s.session_id === id)); }
|
|
218
|
+
assertMessageTarget(principal, id) {
|
|
219
|
+
const record = this.doc.sessions.find(s => s.session_id === id && s.group_id === principal.groupId);
|
|
220
|
+
assertMessageRuntime(record);
|
|
221
|
+
}
|
|
222
|
+
async mutate(principal, key, input, action) {
|
|
223
|
+
const request = this.request(principal, key, input);
|
|
224
|
+
if (request.previous?.result) {
|
|
225
|
+
this.lookup(principal, request.previous.sessionId);
|
|
226
|
+
return structuredClone(request.previous.result);
|
|
227
|
+
}
|
|
228
|
+
const active = this.mutations.get(request.scoped);
|
|
229
|
+
if (active) {
|
|
230
|
+
if (active.hash !== request.hash)
|
|
231
|
+
integrationError('IDEMPOTENCY_CONFLICT', 'Operation key parameters changed', 409);
|
|
232
|
+
return active.promise;
|
|
233
|
+
}
|
|
234
|
+
const promise = action().then(result => {
|
|
235
|
+
if (this.doc.requests[request.scoped]) {
|
|
236
|
+
const before = this.doc.requests[request.scoped];
|
|
237
|
+
this.doc.requests[request.scoped] = { ...before, result: structuredClone(result) };
|
|
238
|
+
try {
|
|
239
|
+
this.save();
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
this.doc.requests[request.scoped] = before;
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}).finally(() => this.mutations.delete(request.scoped));
|
|
248
|
+
this.mutations.set(request.scoped, { hash: request.hash, promise });
|
|
249
|
+
return promise;
|
|
250
|
+
}
|
|
251
|
+
async replayRequest(principal, id, request) {
|
|
252
|
+
const record = await (this.pending.get(id) ?? this.get(principal, id));
|
|
253
|
+
if (request.operationId && (record.operation_id !== request.operationId || record.generation !== request.generation))
|
|
254
|
+
integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Original operation result was not committed and a later generation exists; read session get, do not reuse this key', 409);
|
|
255
|
+
return record;
|
|
256
|
+
}
|
|
257
|
+
async restore(principal, id, key) {
|
|
258
|
+
return this.mutate(principal, key, ['restore', id], () => this.lifecycle.run(id, () => this.restoreOnce(principal, id, key)));
|
|
259
|
+
}
|
|
260
|
+
async restoreOnce(principal, id, idempotencyKey) {
|
|
191
261
|
const request = this.request(principal, idempotencyKey, ['restore', id]);
|
|
192
262
|
if (request.previous)
|
|
193
|
-
return this.
|
|
194
|
-
if (this.pending.has(id)
|
|
263
|
+
return this.replayRequest(principal, id, request.previous);
|
|
264
|
+
if (this.pending.has(id))
|
|
195
265
|
integrationError('SESSION_OPERATION_IN_PROGRESS', 'Another session operation is in progress', 409);
|
|
196
|
-
|
|
266
|
+
let releaseNative;
|
|
197
267
|
try {
|
|
198
268
|
const record = await this.get(principal, id);
|
|
199
269
|
if (!record.agent_native_session_id)
|
|
200
270
|
integrationError('NATIVE_SESSION_ID_MISSING', 'An exact native session binding is required', 409);
|
|
201
271
|
// Recheck the current grant and actual directories; restore uses the
|
|
202
272
|
// original profile snapshot, never changed model or wrapper defaults.
|
|
203
|
-
|
|
273
|
+
const authorizedProfile = principal.launchProfiles.find(p => p.id === record.launch_profile);
|
|
274
|
+
if (!authorizedProfile)
|
|
204
275
|
integrationError('LAUNCH_PROFILE_DENIED', 'Original launch profile is no longer authorized', 403);
|
|
276
|
+
permittedCwd(authorizedProfile, record.cwd);
|
|
205
277
|
permittedCwd(record.profile, record.cwd);
|
|
278
|
+
if (record.operation_uncertain || record.state === 'releasing')
|
|
279
|
+
integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Reconcile prior release using session get before restore', 409);
|
|
206
280
|
const observed = await this.adapter.inspect(record);
|
|
207
281
|
if (observed.running) {
|
|
208
282
|
if (observed.agentSlug !== record.agent_slug || observed.nativeId !== record.agent_native_session_id)
|
|
209
283
|
integrationError('SESSION_IDENTITY_MISMATCH', 'Existing process does not prove the exact native binding', 409);
|
|
210
|
-
this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id };
|
|
284
|
+
this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
|
|
211
285
|
try {
|
|
212
286
|
this.save();
|
|
213
287
|
}
|
|
@@ -221,6 +295,13 @@ export class IntegrationSessions {
|
|
|
221
295
|
integrationError('SESSION_OPERATION_IN_PROGRESS', 'Prior launch outcome is not yet resolved; inspect the original session', 409);
|
|
222
296
|
if (observed.exists && !observed.shell)
|
|
223
297
|
integrationError('SESSION_TARGET_NOT_SHELL', 'Original pane is not a verified shell', 409);
|
|
298
|
+
if (record.state === 'released' && !record.resume_configuration_fingerprint)
|
|
299
|
+
integrationError('RESUME_CONFIGURATION_UNCONFIRMED', 'Administrator must explicitly confirm the current recovery configuration', 409);
|
|
300
|
+
releaseNative = this.lifecycle.reserveNative(record, this.doc.sessions);
|
|
301
|
+
if (record.resume_configuration_fingerprint && record.resume_configuration_fingerprint !== await resumeConfigurationFingerprint(record, this.adapter))
|
|
302
|
+
integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery launcher or plugin semantics changed', 409);
|
|
303
|
+
record.generation = (record.generation ?? 1) + 1;
|
|
304
|
+
record.runtime_present = observed.exists;
|
|
224
305
|
record.state = 'restoring';
|
|
225
306
|
record.error_code = null;
|
|
226
307
|
record.operation_id = randomUUID();
|
|
@@ -230,7 +311,7 @@ export class IntegrationSessions {
|
|
|
230
311
|
if (!record.profile.startupInput && !record.startup_condition)
|
|
231
312
|
record.startup_condition = structuredClone(principal.launchProfiles.find(profile => profile.id === record.launch_profile)?.startupInput);
|
|
232
313
|
record.startup_input = startupInput(record, Date.now());
|
|
233
|
-
this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id };
|
|
314
|
+
this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
|
|
234
315
|
try {
|
|
235
316
|
this.saveRecord(record);
|
|
236
317
|
}
|
|
@@ -241,13 +322,135 @@ export class IntegrationSessions {
|
|
|
241
322
|
return await this.launch(record, true);
|
|
242
323
|
}
|
|
243
324
|
finally {
|
|
244
|
-
|
|
325
|
+
releaseNative?.();
|
|
245
326
|
}
|
|
246
327
|
}
|
|
328
|
+
async resumeConfiguration(id) {
|
|
329
|
+
const record = this.doc.sessions.find(s => s.session_id === id);
|
|
330
|
+
if (!record)
|
|
331
|
+
return integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Recovery record does not exist', 404);
|
|
332
|
+
return { session_id: id, generation: record.generation ?? 1, recorded_fingerprint: record.resume_configuration_fingerprint ?? null,
|
|
333
|
+
current_fingerprint: await resumeConfigurationFingerprint(record, this.adapter), confirmation_required: !record.resume_configuration_fingerprint };
|
|
334
|
+
}
|
|
335
|
+
async confirmResumeConfiguration(id, generation, fingerprint) {
|
|
336
|
+
return this.lifecycle.run(id, async () => {
|
|
337
|
+
const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
|
|
338
|
+
if (!record)
|
|
339
|
+
return integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Recovery record does not exist', 404);
|
|
340
|
+
assertReleaseGeneration(record, generation);
|
|
341
|
+
if (this.pending.has(id) || ['starting', 'restoring', 'binding_pending', 'releasing'].includes(record.state))
|
|
342
|
+
integrationError('SESSION_OPERATION_IN_PROGRESS', 'Resolve the current operation before confirming compatibility', 409);
|
|
343
|
+
if (!/^[a-f0-9]{64}$/.test(fingerprint) || fingerprint !== await resumeConfigurationFingerprint(record, this.adapter))
|
|
344
|
+
integrationError('RESUME_CONFIGURATION_CHANGED', 'Current fingerprint no longer matches the administrator confirmation', 409);
|
|
345
|
+
record.resume_configuration_fingerprint = fingerprint;
|
|
346
|
+
record.generation = generation + 1;
|
|
347
|
+
record.updated_at = Date.now();
|
|
348
|
+
this.saveRecord(record);
|
|
349
|
+
return record;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async release(principal, id, generation, key) {
|
|
353
|
+
return this.mutate(principal, key, ['release', id, generation], () => this.lifecycle.run(id, async () => {
|
|
354
|
+
const request = this.request(principal, key, ['release', id, generation]);
|
|
355
|
+
if (request.previous)
|
|
356
|
+
return this.replayRequest(principal, id, request.previous);
|
|
357
|
+
if (!this.adapter.release || !this.adapter.runtimePresent)
|
|
358
|
+
integrationError('SESSION_RELEASE_UNSUPPORTED', 'Runtime adapter does not support release', 409);
|
|
359
|
+
if (this.pending.has(id))
|
|
360
|
+
integrationError('SESSION_OPERATION_IN_PROGRESS', 'Launch is in progress', 409);
|
|
361
|
+
const record = await this.get(principal, id);
|
|
362
|
+
assertReleaseGeneration(record, generation);
|
|
363
|
+
if (record.state !== 'released') {
|
|
364
|
+
assertReleaseFacts(record, await this.adapter.inspect(record));
|
|
365
|
+
if (this.adapter.pendingMessages?.(id))
|
|
366
|
+
integrationError('SESSION_PENDING_MESSAGES', 'Pending messages must be resolved before release', 409);
|
|
367
|
+
if (!record.resume_configuration_fingerprint)
|
|
368
|
+
integrationError('RESUME_CONFIGURATION_UNCONFIRMED', 'Administrator must inspect and confirm the recovery configuration before release', 409);
|
|
369
|
+
if (record.resume_configuration_fingerprint !== await resumeConfigurationFingerprint(record, this.adapter))
|
|
370
|
+
integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery launcher or plugin semantics changed', 409);
|
|
371
|
+
}
|
|
372
|
+
if (record.state === 'released') {
|
|
373
|
+
this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
|
|
374
|
+
try {
|
|
375
|
+
this.save();
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
delete this.doc.requests[request.scoped];
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
return record;
|
|
382
|
+
}
|
|
383
|
+
if (this.adapter.prepareRelease)
|
|
384
|
+
record.group_role = (await this.adapter.prepareRelease(record)).group_role;
|
|
385
|
+
record.state = 'releasing';
|
|
386
|
+
record.operation_id = randomUUID();
|
|
387
|
+
record.generation = generation + 1;
|
|
388
|
+
record.operation_uncertain = false;
|
|
389
|
+
record.error_code = null;
|
|
390
|
+
record.updated_at = Date.now();
|
|
391
|
+
this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
|
|
392
|
+
try {
|
|
393
|
+
this.saveRecord(record);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
delete this.doc.requests[request.scoped];
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await this.adapter.release(record, false);
|
|
401
|
+
if (await this.adapter.runtimePresent(record))
|
|
402
|
+
integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Runtime removal could not be confirmed', 409);
|
|
403
|
+
record.state = 'released';
|
|
404
|
+
record.runtime_present = false;
|
|
405
|
+
record.terminal_binding = null;
|
|
406
|
+
record.startup_input = undefined;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
record.state = 'failed';
|
|
410
|
+
record.operation_uncertain = true;
|
|
411
|
+
record.runtime_present = null;
|
|
412
|
+
record.error_code = typeof error.code === 'string' ? error.code : 'SESSION_RELEASE_FAILED';
|
|
413
|
+
}
|
|
414
|
+
record.updated_at = Date.now();
|
|
415
|
+
this.saveRecord(record);
|
|
416
|
+
return record;
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
247
419
|
async refresh(id) {
|
|
248
420
|
const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
|
|
249
421
|
if (!record)
|
|
250
422
|
return;
|
|
423
|
+
if (record.state === 'released' || record.state === 'releasing' || record.operation_uncertain) {
|
|
424
|
+
if (this.lifecycle.active(id))
|
|
425
|
+
return;
|
|
426
|
+
try {
|
|
427
|
+
if (record.state !== 'released')
|
|
428
|
+
await this.adapter.release?.(record, true);
|
|
429
|
+
const present = await this.adapter.runtimePresent?.(record);
|
|
430
|
+
if (present === undefined)
|
|
431
|
+
integrationError('SESSION_RELEASE_UNSUPPORTED', 'Runtime presence is unavailable', 409);
|
|
432
|
+
if (record.state !== 'released') {
|
|
433
|
+
if (present)
|
|
434
|
+
integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Prior release still has a runtime; explicit release retry is required', 409);
|
|
435
|
+
// Absent runtime metadata has been reconciled without killing a live runtime.
|
|
436
|
+
}
|
|
437
|
+
else if (present)
|
|
438
|
+
integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'A runtime exists for a released descriptor', 409);
|
|
439
|
+
const next = { ...record, state: 'released', runtime_present: false, terminal_binding: null, startup_input: undefined, operation_uncertain: false, error_code: null };
|
|
440
|
+
if (JSON.stringify(this.doc.sessions.find(s => s.session_id === id)) !== JSON.stringify(record))
|
|
441
|
+
return;
|
|
442
|
+
if (JSON.stringify(next) !== JSON.stringify(record))
|
|
443
|
+
this.saveRecord({ ...next, updated_at: Date.now() });
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (JSON.stringify(this.doc.sessions.find(s => s.session_id === id)) !== JSON.stringify(record))
|
|
447
|
+
return;
|
|
448
|
+
const next = { ...record, state: 'failed', runtime_present: null, operation_uncertain: true, error_code: typeof error.code === 'string' ? error.code : 'OPERATION_OUTCOME_UNCONFIRMED' };
|
|
449
|
+
if (JSON.stringify(next) !== JSON.stringify(record))
|
|
450
|
+
this.saveRecord({ ...next, updated_at: Date.now() });
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
251
454
|
let observed;
|
|
252
455
|
try {
|
|
253
456
|
observed = await this.adapter.inspect(record);
|
|
@@ -261,7 +464,7 @@ export class IntegrationSessions {
|
|
|
261
464
|
this.saveRecord({ ...record, state: 'failed', error_code: typeof code === 'string' ? code : 'SESSION_OBSERVATION_FAILED', updated_at: Date.now() });
|
|
262
465
|
return;
|
|
263
466
|
}
|
|
264
|
-
const next = { ...record };
|
|
467
|
+
const next = { ...record, runtime_present: this.adapter.runtimePresent ? await this.adapter.runtimePresent(record) : observed.exists };
|
|
265
468
|
if (observed.nativeId && (typeof observed.nativeId !== 'string' || observed.nativeId.length > 256 || /[\x00-\x1f\x7f]/.test(observed.nativeId))) {
|
|
266
469
|
next.state = 'failed';
|
|
267
470
|
next.error_code = 'NATIVE_SESSION_ID_INVALID';
|
|
@@ -325,7 +528,12 @@ export class IntegrationSessions {
|
|
|
325
528
|
}
|
|
326
529
|
/** Only gates configured launches. Native UUID and Agent turn state are not input prerequisites. */
|
|
327
530
|
async deliveryReadiness(id) {
|
|
328
|
-
|
|
531
|
+
const existing = this.doc.sessions.find(record => record.session_id === id);
|
|
532
|
+
if (!existing)
|
|
533
|
+
return null;
|
|
534
|
+
if (existing.state === 'released' || existing.state === 'releasing' || existing.operation_uncertain)
|
|
535
|
+
return { allowed: false, reason: existing.state === 'released' ? 'SESSION_RELEASED' : 'SESSION_OPERATION_IN_PROGRESS' };
|
|
536
|
+
if (!existing.startup_input)
|
|
329
537
|
return null;
|
|
330
538
|
await this.refresh(id);
|
|
331
539
|
const record = this.doc.sessions.find(record => record.session_id === id);
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { CollaborationError } from './collaborationProtocol.js';
|
|
5
5
|
export const INTEGRATION_PROTOCOL = 1;
|
|
6
|
-
export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore'];
|
|
6
|
+
export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore', 'session.release'];
|
|
7
7
|
export function integrationError(code, message, status = 400) { throw new CollaborationError(code, message, status); }
|
|
8
8
|
export function atomicJson(file, value) {
|
|
9
9
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { CollaborationError } from './collaborationProtocol.js';
|
|
2
|
+
const fail = (code, message) => { throw new CollaborationError(code, message, 409); };
|
|
3
|
+
/** One coordinator per service. A reservation covers validation and launch;
|
|
4
|
+
* persisted unresolved intents also block a second identity after restart.
|
|
5
|
+
* This does not promise atomic exclusion of independently launched programs. */
|
|
6
|
+
export class SessionLifecycleCoordinator {
|
|
7
|
+
operations = new Set();
|
|
8
|
+
natives = new Map();
|
|
9
|
+
async run(id, action) {
|
|
10
|
+
if (this.operations.has(id))
|
|
11
|
+
fail('SESSION_OPERATION_IN_PROGRESS', 'Another lifecycle operation is in progress');
|
|
12
|
+
this.operations.add(id);
|
|
13
|
+
try {
|
|
14
|
+
return await action();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
this.operations.delete(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
active(id) { return this.operations.has(id); }
|
|
21
|
+
reserveNative(record, descriptors) {
|
|
22
|
+
const key = JSON.stringify([record.agent_slug, record.agent_native_session_id]);
|
|
23
|
+
const owner = this.natives.get(key);
|
|
24
|
+
if (owner && owner !== record.session_id || descriptors.some(other => other.session_id !== record.session_id
|
|
25
|
+
&& other.agent_slug === record.agent_slug && other.agent_native_session_id === record.agent_native_session_id
|
|
26
|
+
&& (['starting', 'restoring', 'binding_pending'].includes(other.state) || other.operation_uncertain))) {
|
|
27
|
+
fail('SESSION_NATIVE_OPERATION_IN_PROGRESS', 'Another descriptor has an unresolved operation for this native identity');
|
|
28
|
+
}
|
|
29
|
+
this.natives.set(key, record.session_id);
|
|
30
|
+
return () => { if (this.natives.get(key) === record.session_id)
|
|
31
|
+
this.natives.delete(key); };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function assertReleaseGeneration(record, generation) {
|
|
35
|
+
if (!Number.isSafeInteger(generation) || Number(generation) < 1)
|
|
36
|
+
fail('INVALID_SESSION_GENERATION', 'release requires the generation returned by session get');
|
|
37
|
+
if (generation !== (record.generation ?? 1))
|
|
38
|
+
fail('SESSION_GENERATION_MISMATCH', 'Session generation changed; read its current state before releasing');
|
|
39
|
+
}
|
|
40
|
+
export function assertReleaseFacts(record, facts) {
|
|
41
|
+
if (!record.agent_native_session_id)
|
|
42
|
+
fail('NATIVE_SESSION_ID_MISSING', 'Release requires a durable exact native identity');
|
|
43
|
+
if (['starting', 'restoring', 'binding_pending', 'releasing'].includes(record.state))
|
|
44
|
+
fail('SESSION_OPERATION_IN_PROGRESS', 'Prior operation is unresolved');
|
|
45
|
+
if (facts.running)
|
|
46
|
+
fail('SESSION_STILL_RUNNING', 'Agent is still running; explicitly exit it before releasing the runtime');
|
|
47
|
+
if (facts.exists && !facts.shell)
|
|
48
|
+
fail('SESSION_IDENTITY_MISMATCH', 'Runtime is not the verified original empty shell');
|
|
49
|
+
}
|
|
50
|
+
export function assertMessageRuntime(record) {
|
|
51
|
+
if (!record)
|
|
52
|
+
return;
|
|
53
|
+
if (record.state === 'released')
|
|
54
|
+
fail('SESSION_RELEASED', 'Runtime was released; explicitly restore this session before sending');
|
|
55
|
+
if (record.state === 'releasing')
|
|
56
|
+
fail('SESSION_OPERATION_IN_PROGRESS', 'Runtime release is in progress; message was not enqueued');
|
|
57
|
+
if (record.operation_uncertain)
|
|
58
|
+
fail('OPERATION_OUTCOME_UNCONFIRMED', 'Lifecycle outcome needs reconciliation before sending');
|
|
59
|
+
}
|
|
60
|
+
/** Shared release protocol. No Agent idle inference, task result, or UI
|
|
61
|
+
* visibility participates in deciding whether a process may be destroyed. */
|
|
62
|
+
export async function releaseSessionRuntime(adapter, reconcile) {
|
|
63
|
+
await adapter.barrier(async () => {
|
|
64
|
+
if (adapter.pendingMessages())
|
|
65
|
+
fail('SESSION_PENDING_MESSAGES', 'Pending messages appeared before runtime release');
|
|
66
|
+
if (await adapter.exists()) {
|
|
67
|
+
if (reconcile)
|
|
68
|
+
fail('OPERATION_OUTCOME_UNCONFIRMED', 'Original runtime still exists; restart reconciliation will not terminate it');
|
|
69
|
+
await adapter.verifyOwnership();
|
|
70
|
+
const facts = await adapter.inspect();
|
|
71
|
+
if (facts.running)
|
|
72
|
+
fail('SESSION_STILL_RUNNING', 'Agent restarted before release');
|
|
73
|
+
if (!facts.exists || !facts.shell)
|
|
74
|
+
fail('SESSION_IDENTITY_MISMATCH', 'Original runtime is not a verified foreground shell');
|
|
75
|
+
await adapter.destroy();
|
|
76
|
+
}
|
|
77
|
+
if (await adapter.exists())
|
|
78
|
+
fail('OPERATION_OUTCOME_UNCONFIRMED', 'Owned runtime still exists after release');
|
|
79
|
+
await adapter.forgetRuntime();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { CollaborationError } from './collaborationProtocol.js';
|
|
4
|
+
const fail = (code, message) => { throw new CollaborationError(code, message, 409); };
|
|
5
|
+
/** Fingerprint only the launcher file and recovery semantics. This does not
|
|
6
|
+
* claim to fingerprint its dependencies, private Agent binaries, or history. */
|
|
7
|
+
export async function resumeConfigurationFingerprint(record, adapter) {
|
|
8
|
+
const semantics = await adapter.resumeConfiguration?.(record);
|
|
9
|
+
if (semantics === undefined)
|
|
10
|
+
fail('EXACT_RESUME_UNSUPPORTED', 'Adapter does not expose exact recovery semantics');
|
|
11
|
+
try {
|
|
12
|
+
const executable = fs.realpathSync(record.profile.executable);
|
|
13
|
+
const before = fs.statSync(executable);
|
|
14
|
+
if (!before.isFile() || before.size > 512 * 1024 * 1024)
|
|
15
|
+
fail('LAUNCHER_UNAVAILABLE', 'Launcher must be a regular file within the fingerprint size limit');
|
|
16
|
+
fs.accessSync(executable, fs.constants.X_OK);
|
|
17
|
+
const hash = createHash('sha256');
|
|
18
|
+
for await (const chunk of fs.createReadStream(executable))
|
|
19
|
+
hash.update(chunk);
|
|
20
|
+
const after = fs.statSync(executable);
|
|
21
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs
|
|
22
|
+
|| fs.realpathSync(record.profile.executable) !== executable)
|
|
23
|
+
fail('RESUME_CONFIGURATION_CHANGED', 'Launcher changed while fingerprinting');
|
|
24
|
+
return createHash('sha256').update(JSON.stringify({ version: 1, executable, content: hash.digest('hex'),
|
|
25
|
+
agent: record.agent_slug, argv: record.profile.argv, resumeArgv: record.profile.resumeArgv, cwd: record.cwd, semantics })).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof CollaborationError)
|
|
29
|
+
throw error;
|
|
30
|
+
return fail('LAUNCHER_UNAVAILABLE', 'Recovery launcher could not be read and verified');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -2,6 +2,8 @@ import { collaborationSessionDisplayName } from '../utils/sessionDisplayName.js'
|
|
|
2
2
|
import { integrationError } from '../agent/integrationStore.js';
|
|
3
3
|
import { assertNativeResumeAvailable, collectNativeResumeOwnerCandidates } from '../agent/nativeResumeOwner.js';
|
|
4
4
|
import { readNativeProcessIdentity } from '../agent/nativeProcessIdentity.js';
|
|
5
|
+
import { releaseSessionRuntime } from '../agent/sessionLifecycle.js';
|
|
6
|
+
import { resumeConfigurationFingerprint } from '../agent/sessionResumeConfiguration.js';
|
|
5
7
|
import { integrationLaunchCommand } from '../agent/integrationSessions.js';
|
|
6
8
|
import { ensureTeam } from '../agent/collaborationTeam.js';
|
|
7
9
|
import { androidRecordings } from '../android/recording.js';
|
|
@@ -39,7 +41,7 @@ import { inspectLinuxInotifyUsage, isWatchResourceExhaustion } from '../utils/fi
|
|
|
39
41
|
import { writeResponseChunk } from '../utils/httpResponse.js';
|
|
40
42
|
import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
|
|
41
43
|
import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, } from '../utils/tmuxProgramDetection.js';
|
|
42
|
-
import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, getPluginNativeIdentityConfig, listAgents, } from '../agent/registry.js';
|
|
44
|
+
import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, getPluginNativeIdentityConfig, getPluginResumeConfig, listAgents, } from '../agent/registry.js';
|
|
43
45
|
import { applyAgentEvent, agentEventMatchesCurrentAgent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
|
|
44
46
|
import { canRestoreDeadAgentShell, isConfirmedAgentResumeProcess, normalizePersistedAgentResumeInfo, } from '../agent/resumePersistence.js';
|
|
45
47
|
import { AgentResumeHistoryStore } from '../agent/resumeHistory.js';
|
|
@@ -1861,10 +1863,76 @@ function integrationTerminalRecord(record) {
|
|
|
1861
1863
|
integrationError('SESSION_IDENTITY_MISMATCH', 'Owned TD terminal binding changed', 409);
|
|
1862
1864
|
return terminal;
|
|
1863
1865
|
}
|
|
1866
|
+
async function integrationTmuxPresent(record) {
|
|
1867
|
+
try {
|
|
1868
|
+
await runTmux(['has-session', '-t', `=wt-integration-${record.session_id}`]);
|
|
1869
|
+
return true;
|
|
1870
|
+
}
|
|
1871
|
+
catch (error) {
|
|
1872
|
+
if (/can't find session|no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error)))
|
|
1873
|
+
return false;
|
|
1874
|
+
throw error;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1864
1877
|
export const integrationSessionAdapter = {
|
|
1865
1878
|
subscribe(listener) { integrationSessionObservers.add(listener); return () => { integrationSessionObservers.delete(listener); }; },
|
|
1866
1879
|
registerDeliveryGuard(guard) { integrationDeliveryGuard = guard; return () => { if (integrationDeliveryGuard === guard)
|
|
1867
1880
|
integrationDeliveryGuard = undefined; }; },
|
|
1881
|
+
pendingMessages(id) { return collaborationStore.pendingCount(id); },
|
|
1882
|
+
async prepareRelease(record) {
|
|
1883
|
+
if (collaborationStore.groupsForSession(record.session_id).some(group => group.id !== record.group_id))
|
|
1884
|
+
integrationError('SESSION_SCOPE_CONFLICT', 'Runtime belongs to another collaboration group; administrator must resolve its membership first', 409);
|
|
1885
|
+
return { group_role: collaborationStore.getGroup(record.group_id)?.roles?.[record.session_id] };
|
|
1886
|
+
},
|
|
1887
|
+
async resumeConfiguration(record) {
|
|
1888
|
+
await listDetectedAgentLaunchers();
|
|
1889
|
+
const agent = agentBySlug(record.agent_slug);
|
|
1890
|
+
const command = agent && buildResumeCommand(agent, 'td-fingerprint-native-id', null);
|
|
1891
|
+
if (!agent || !command)
|
|
1892
|
+
integrationError('EXACT_RESUME_UNSUPPORTED', 'Agent must support exact native recovery', 409);
|
|
1893
|
+
return { version: 1, slug: agent.slug, aliases: agent.aliases, capabilities: agent.capabilities ?? [],
|
|
1894
|
+
resume: getPluginResumeConfig(agent.slug) ?? command, nativeIdentity: getPluginNativeIdentityConfig(agent.slug) ?? null };
|
|
1895
|
+
},
|
|
1896
|
+
async runtimePresent(record) {
|
|
1897
|
+
if (integrationTerminalRecord(record))
|
|
1898
|
+
return true;
|
|
1899
|
+
if ([...terminalSessions.values()].some(s => s.mode === 'tmux' && s.tmuxSessionName === `wt-integration-${record.session_id}`))
|
|
1900
|
+
return true;
|
|
1901
|
+
if (collaborationStore.getGroup(record.group_id)?.sessionIds.includes(record.session_id))
|
|
1902
|
+
return true;
|
|
1903
|
+
return integrationTmuxPresent(record);
|
|
1904
|
+
},
|
|
1905
|
+
async release(record, reconcile) {
|
|
1906
|
+
await releaseSessionRuntime({
|
|
1907
|
+
barrier: action => collaborationDeliveryWorker.reconfigure(record.session_id, action),
|
|
1908
|
+
exists: () => integrationTmuxPresent(record),
|
|
1909
|
+
pendingMessages: () => collaborationStore.pendingCount(record.session_id),
|
|
1910
|
+
async verifyOwnership() {
|
|
1911
|
+
await integrationSessionAdapter.prepareRelease(record);
|
|
1912
|
+
const terminal = integrationTerminalRecord(record);
|
|
1913
|
+
if (!terminal || !record.terminal_binding)
|
|
1914
|
+
integrationError('SESSION_IDENTITY_MISMATCH', 'Live runtime lacks its exact persisted binding', 409);
|
|
1915
|
+
if (globalSessionState.sessions.some(other => other.sessionId !== record.session_id && (other.tmuxSessionName === terminal.tmuxSessionName
|
|
1916
|
+
|| terminal.backendSessionId && other.backendSessionId === terminal.backendSessionId)))
|
|
1917
|
+
integrationError('SESSION_IDENTITY_MISMATCH', 'Runtime has another logical owner', 409);
|
|
1918
|
+
},
|
|
1919
|
+
inspect: () => integrationSessionAdapter.inspect(record),
|
|
1920
|
+
destroy: async () => { await destroyTmuxSessionSafely(`wt-integration-${record.session_id}`); },
|
|
1921
|
+
async forgetRuntime() {
|
|
1922
|
+
// Only remove exact owned PTY/inventory records after actual tmux absence.
|
|
1923
|
+
integrationTerminalRecord(record);
|
|
1924
|
+
await integrationSessionAdapter.prepareRelease(record);
|
|
1925
|
+
for (const [id, backend] of terminalSessions)
|
|
1926
|
+
if (backend.mode === 'tmux' && backend.tmuxSessionName === `wt-integration-${record.session_id}`)
|
|
1927
|
+
cleanupSession(id, { killProcess: true });
|
|
1928
|
+
removeGlobalSessionRecord(record.session_id);
|
|
1929
|
+
collaborationStore.archiveSession(record.session_id);
|
|
1930
|
+
collaborationRouting.remove(record.session_id);
|
|
1931
|
+
await persistGlobalStateNow();
|
|
1932
|
+
broadcastClientState();
|
|
1933
|
+
},
|
|
1934
|
+
}, reconcile);
|
|
1935
|
+
},
|
|
1868
1936
|
async capture(record) {
|
|
1869
1937
|
const observed = await integrationSessionAdapter.inspect(record);
|
|
1870
1938
|
if (!observed.exists || !observed.running || observed.agentSlug !== record.agent_slug || !record.terminal_binding
|
|
@@ -2011,12 +2079,18 @@ async function launchIntegrationTerminal(record, restoring, prepared) {
|
|
|
2011
2079
|
if (!latest || latest.deleted)
|
|
2012
2080
|
integrationError('TASK_GROUP_NOT_FOUND', 'Integration group changed', 409);
|
|
2013
2081
|
collaborationStore.save({ id: latest.id, name: latest.name, sessionIds: [...new Set([...latest.sessionIds, record.session_id])] });
|
|
2082
|
+
if (restoring && record.group_role)
|
|
2083
|
+
collaborationStore.setRole({ groupId: latest.id, sessionId: record.session_id, role: record.group_role });
|
|
2014
2084
|
if (restoring)
|
|
2015
2085
|
upsertGlobalSessionRecord({ ...terminal, agentResume: { slug: record.agent_slug, sessionId: record.agent_native_session_id,
|
|
2016
2086
|
launchArgv: [record.profile.executable, ...record.profile.argv], updatedAt: Date.now() } });
|
|
2017
2087
|
await persistGlobalStateNow();
|
|
2018
2088
|
// Recheck the actual pinned shell immediately before writing. This cannot
|
|
2019
2089
|
// use an active-pane target which might have changed during preparation.
|
|
2090
|
+
if (record.resume_configuration_fingerprint && record.resume_configuration_fingerprint !== await resumeConfigurationFingerprint(record, integrationSessionAdapter))
|
|
2091
|
+
integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery configuration changed before terminal input', 409);
|
|
2092
|
+
if (restoring)
|
|
2093
|
+
await assertIntegrationNativeResumeAvailable(record);
|
|
2020
2094
|
const shell = await inspectCollaborationTmux({ sessionId: record.session_id, backendSessionId: opened.terminalSession.sessionId,
|
|
2021
2095
|
mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: null, nativeSessionId: null, pane: pinned });
|
|
2022
2096
|
if (shell.state !== 'ready' || !shell.pane || !await integrationPaneIsIdleShell(shell.pane))
|
package/docs/integrations.md
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
直接 exec 为 pane PID 的原生 argv 核验及可选 Linux 持锁身份要求 **实际服务至少 1.4.306**;
|
|
10
10
|
后台执行记录与用途迁移要求 **CLI / 实际服务均至少 1.4.307**;
|
|
11
11
|
普通消息身份发送、持久回复及消息历史要求 **CLI / 实际服务均至少 1.4.308**;
|
|
12
|
-
|
|
12
|
+
释放运行承载、稳定逻辑身份与恢复配置确认要求 **CLI / 实际服务均至少 1.4.309**;
|
|
13
|
+
建议 CLI 与服务同步安装 1.4.309(既有接入命令的 CLI 最低仍为 1.4.301,诊断 CLI 最低为 1.4.305)。
|
|
13
14
|
现有 `--session`、消息投递、任务报告与自动协作入口继续兼容;旧的
|
|
14
15
|
`task create --integration` 仍表示代码集成子任务,新身份使用 `--principal`。
|
|
15
16
|
|
|
@@ -389,7 +390,7 @@ task 事件 `event_id` 与任务内事件 ID 一致,`respond.reply_to_event_id
|
|
|
389
390
|
| `task.delivery` | `{delivery}`:任务投递 id、attemptId、messageId、status、deliveredAt、error |
|
|
390
391
|
| `message.queued/message.reply` | `{message_id,thread_id,reply_to,from_session_id,to_session_id,to_principal_id,integration_origin,response_kind,stored_at,content,metadata}` |
|
|
391
392
|
| `message.delivery` | `{receipt}`:实际入队/远端接收/终端写入/后台回复保存阶段、失败、重试计数与时间,及明确回复 ID;不含快照或已读字段 |
|
|
392
|
-
| `session.starting/binding_pending/ready/restoring/failed` | `{session}`:下节公开会话记录,失败包含 error_code |
|
|
393
|
+
| `session.starting/binding_pending/ready/restoring/releasing/released/failed` | `{session}`:下节公开会话记录,失败包含 error_code |
|
|
393
394
|
|
|
394
395
|
TD 在源记录提交后触发 journal 及订阅,journal 中的同一事件 ID 在重连重放时保持不变。
|
|
395
396
|
新 consumer 从最早保留事件开始;接入方按 `event_id` 去重,**先持久写入 inbox,
|
|
@@ -438,7 +439,9 @@ agent_native_session_id,state,error_code,cwd,launch_profile,created_at,updated_a
|
|
|
438
439
|
| binding_pending | 实际观察到该 Agent,尚未捕获确切原生 ID |
|
|
439
440
|
| ready | 实际观察到该 Agent 和确切原生 ID;不表示任务空闲/完成 |
|
|
440
441
|
| restoring | 显式恢复意图持久化,等待原 ID 的运行证据 |
|
|
441
|
-
|
|
|
442
|
+
| releasing | 释放意图已落盘;不能接收新投递,尚未证明实际承载回收 |
|
|
443
|
+
| released | 实际进程/PTY/tmux/活动终端登记与本组活动成员已移除;恢复描述保留 |
|
|
444
|
+
| failed | 启动、绑定、身份核验、释放或运行观察失败,详见 error_code |
|
|
442
445
|
|
|
443
446
|
同键同请求返回原 TD ID;已记录但结果不明的启动不会自动重新执行。通常等
|
|
444
447
|
session 事件;get 也会现场核验。120 秒内未形成绑定返回 `SESSION_BINDING_TIMEOUT`。
|
|
@@ -526,6 +529,133 @@ PID、同 ID 拒绝/不同 ID 排除、遗留 owner、错误 start_id/PID、inod
|
|
|
526
529
|
持锁、多个 ID 和 argv 冲突。**私有 TraeX 的 1.4.306 身份接入仍需使用方实机复验**。
|
|
527
530
|
原 `startupInput` 仍独立控制首次写入;核验 native ID 不表示输入初始化完成。
|
|
528
531
|
|
|
532
|
+
### 会话身份与运行承载的生命周期(1.4.309)
|
|
533
|
+
|
|
534
|
+
这项能力把稳定的会话记录与可回收的终端承载分开。TD 保留逻辑 `session_id`、
|
|
535
|
+
原生身份、授权归属、原启动配置和消息关联;terminal/backend/tmux/pane 是可重建的
|
|
536
|
+
运行承载。`released` 不进入活动终端列表,没有常驻 Agent、PTY 或 tmux。
|
|
537
|
+
它不是隐藏的终端,也不表示删除 Agent 原生历史。原生历史仍由 Agent 管理。
|
|
538
|
+
|
|
539
|
+
共享内核 `sessionLifecycle.ts` 提供操作串行、原生身份预留、代次和释放验证协议;
|
|
540
|
+
首批调用者为现有 IntegrationSession/受限 CLI。复用原有持久恢复记录,不新增
|
|
541
|
+
conversation/job/task 或裸 UUID 导入。现有用户关闭、最近关闭列表、任务执行归档
|
|
542
|
+
保持现有行为,本版尚未将它们迁入该协议。principal 是调用权限,不是业务类型。
|
|
543
|
+
|
|
544
|
+
```sh
|
|
545
|
+
# 管理员将 session.release 追加到原身份的 permissions,凭据保持不变。
|
|
546
|
+
td integration update --file /private/path/policy.json
|
|
547
|
+
# 后台从 get 获取当前 generation(不能硬编码,也不能仅沿用旧事件中的代次)。
|
|
548
|
+
td collab --principal report-bridge session get <session-id>
|
|
549
|
+
td collab --principal report-bridge session release <session-id> \
|
|
550
|
+
--generation <current-generation> --idempotency-key <this-release-key>
|
|
551
|
+
# 下一条外部消息先持久接收,显式恢复,再 send/reply。
|
|
552
|
+
td collab --principal report-bridge session restore <session-id> \
|
|
553
|
+
--idempotency-key <this-restore-key>
|
|
554
|
+
td collab --principal report-bridge reply <retained-original-message-id> \
|
|
555
|
+
--file /private/path/followup.txt --idempotency-key <this-message-key>
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
权限:`session.release` 与 `session.restore` 分开授权,`session.read` 用于状态回读。
|
|
559
|
+
必须仍属于原 principal/本机组;没有 `session.release` 的旧身份不会获得该能力。
|
|
560
|
+
运行中 Agent 必须先由调用方明确正常退出;TD 不根据 result、快照、输出间隔或
|
|
561
|
+
忙闲推断代为终止。首版验证固定的单 pane、原 shell PID/前台进程组和纯交互 shell
|
|
562
|
+
参数,没有 Agent 或其它前台工作。shell 中人工输入的未执行文字不属于可靠观测,
|
|
563
|
+
调用方需保证不与人工输入同时操作。无原生 ID、未知进程、进行中的启动/恢复、
|
|
564
|
+
pending 消息或其它组的成员归属均拒绝;不取消 pending、不补投 delivered 正文。
|
|
565
|
+
|
|
566
|
+
新增公开字段:`generation`(旧记录初始为 1)、`runtime_present`(true/false/null)、
|
|
567
|
+
`operation_uncertain`(可选)、`resume_configuration_fingerprint`(可选)。
|
|
568
|
+
`runtime_present=null` 表示观测/操作结果不明,不能解释为零资源;shell 仍存在时
|
|
569
|
+
为 true。成功 `released` 必须 `runtime_present=false,terminal_binding=null,error_code=null`。
|
|
570
|
+
释放保存的原组角色在恢复时重建,组、消息、结果、旧任务不会随释放删除。
|
|
571
|
+
|
|
572
|
+
实际释放和实际恢复在执行前递增 generation;管理员兼容确认也递增,以使旧观察
|
|
573
|
+
失效。已经 released 的新键 release、精确运行中的新键 restore 为无动作回读,保持
|
|
574
|
+
原 operation/generation。release 必须携带 get 返回的当前代次,旧值报
|
|
575
|
+
`SESSION_GENERATION_MISMATCH`;代次不等于消息序号、任务 attempt 或业务周期。
|
|
576
|
+
从本版开始完成的 release/restore 幂等键重放返回**该操作原结果快照**,可能已不是
|
|
577
|
+
当前状态;使用 get 查当前状态。不同参数复用键返回 `IDEMPOTENCY_CONFLICT`,
|
|
578
|
+
下一次实际操作使用新键。若崩溃发生在结果快照提交前,且会话已经进入后续代次,
|
|
579
|
+
旧键返回 OPERATION_OUTCOME_UNCONFIRMED,不把新操作结果冒充旧结果。升级前的旧幂等
|
|
580
|
+
记录仍按兼容规则回读当前状态,不重复执行。
|
|
581
|
+
|
|
582
|
+
同 ID 的生命周期串行;同 Agent/native ID 的验证/启动使用服务内互斥预留,重启后
|
|
583
|
+
持久的未决启动意图也阻挡另一描述启动。同一键的并发请求共用原操作,不启动两份。
|
|
584
|
+
原生身份检查继续使用实际 argv/当前持锁证据;该预留不是独立程序之间的全局原子锁。
|
|
585
|
+
释放先保存意图,再阻断新投递、等待正在进行的投递,重新核验后销毁,最后保存结果。
|
|
586
|
+
落盘失败不先执行回收;回收失败保留描述并报告 failed,不宣称 released。
|
|
587
|
+
|
|
588
|
+
重启对 releasing/不确定记录核验实际承载:已经不存在时可以清理残留登记并收口,
|
|
589
|
+
仍存在时返回 `OPERATION_OUTCOME_UNCONFIRMED`,不会自动杀进程或再启动 Agent。
|
|
590
|
+
调用方读 get/诊断、明确处理现场后,可用当前 generation 和新键再次请求 release。
|
|
591
|
+
Released 记录若出现未授权重建的承载,同样拒绝静默接管。
|
|
592
|
+
`session.releasing/released/restoring/ready/failed` 推送原 operation_id/generation;
|
|
593
|
+
沿用现有 event_id 去重、连续持久接收 ACK、断线重放与保留缺口契约。
|
|
594
|
+
|
|
595
|
+
Released 上 send/reply 返回 `SESSION_RELEASED`,不入队、不隐式启动。恢复重建承载后
|
|
596
|
+
逻辑 ID 不变,原 retained 消息的 reply_to、thread_id 与 principal 关联不需要迁移。
|
|
597
|
+
恢复返回 ready 只证明原生身份,消息仍受本次 startupInput 保护。恢复失败不得静默
|
|
598
|
+
创建另一 UUID、使用 --last,或改走新的业务任务/会话。
|
|
599
|
+
|
|
600
|
+
**历史保留边界不变**:普通消息仍只覆盖保留记录(通常最近 2,000 条及待投递/近期
|
|
601
|
+
幂等保护记录),每组事件仍保留 10,000 条。释放不是无限历史锚点;原消息裁剪后
|
|
602
|
+
reply 返回 `MESSAGE_NOT_FOUND`,不能伪造旧 reply_to。是否用同恢复会话的新普通
|
|
603
|
+
消息作为新咨询锚点由业务方决定,并保留自己的外部历史。没有 forget 或删除原生
|
|
604
|
+
历史接口;用户放弃历史与释放承载是不同操作,原生历史删除不在本版范围。
|
|
605
|
+
|
|
606
|
+
#### 恢复配置兼容性与旧记录初始化
|
|
607
|
+
|
|
608
|
+
新建可精确恢复的记录在启动前保存配置指纹。指纹涵盖当前 launcher 的真实路径、
|
|
609
|
+
文件 SHA-256、原 argv/resumeArgv/cwd、Agent 别名、精确 resume 语义、插件 nativeIdentity
|
|
610
|
+
声明。它不是整个私有 Agent 安装包/包装器依赖/原生历史的备份,也不会检查私有
|
|
611
|
+
会话正文。包装器路径相同不能证明内容相同;文件超过 512 MiB 不支持指纹采集。
|
|
612
|
+
现有不能精确 resume 的插件仍可创建终端,但不因此获得安全释放能力。
|
|
613
|
+
|
|
614
|
+
旧记录未保存历史文件内容,升级时只初始化代次,不自动采集为“历史已验证”。
|
|
615
|
+
第一次 release 前,管理员先读取**当前**指纹,核对原模型/参数/cwd、包装器当前内容
|
|
616
|
+
和插件恢复语义,再明确授权当前配置用于原历史恢复:
|
|
617
|
+
|
|
618
|
+
```sh
|
|
619
|
+
td integration resume-configuration <session-id>
|
|
620
|
+
# 返回 generation, recorded_fingerprint(旧记录为 null), current_fingerprint,
|
|
621
|
+
# confirmation_required。无 shell/PTY/状态改变,不返回文件正文或凭据。
|
|
622
|
+
td integration confirm-resume <session-id> \
|
|
623
|
+
--generation <returned-generation> --fingerprint <current_fingerprint>
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
确认只证明管理员接受当前兼容性,**不证明未知的旧包装器内容**;不改 argv/profile/
|
|
627
|
+
cwd/native UUID/私有程序。后来指纹不一致时明确失败,管理员也可在核对后用上述
|
|
628
|
+
步骤确认新兼容指纹;TD 不暗中迁移模型或参数。正在执行生命周期操作时不允许确认。
|
|
629
|
+
恢复继续使用原 profile 快照;同时按当前授权的 profile/cwdRoots 复核 cwd,撤销或
|
|
630
|
+
缩小授权不被旧快照绕过。插件精确恢复能力消失或身份/启动条件失败仍会拒绝。
|
|
631
|
+
|
|
632
|
+
能力检测:CLI/实际服务均至少 1.4.309,协议 1;服务 capabilities 必须包含
|
|
633
|
+
`session_runtime_release=true,stable_session_identity=true,session_lifecycle_generation=true,
|
|
634
|
+
session_resume_configuration=true,session_lifecycle_min_version="1.4.309"`。
|
|
635
|
+
CLI 对旧服务先检查能力,失败为 `SESSION_RELEASE_UNSUPPORTED`,不发送回收请求。
|
|
636
|
+
本地管理员配置确认同样先探测服务能力。未知或不兼容错误不得转为新会话。
|
|
637
|
+
|
|
638
|
+
| 错误码 | 准确语义及处理 |
|
|
639
|
+
| --- | --- |
|
|
640
|
+
| SESSION_RELEASE_UNSUPPORTED | CLI/实际服务或运行适配器不支持本协议;升级两者 |
|
|
641
|
+
| INVALID_SESSION_GENERATION | 缺少或非法代次;从 get 获取正整数 |
|
|
642
|
+
| SESSION_GENERATION_MISMATCH | 观察已过时;重新读当前记录,不硬改旧请求 |
|
|
643
|
+
| SESSION_STILL_RUNNING | 实际 Agent 仍在运行;不能推断空闲后自动退出 |
|
|
644
|
+
| SESSION_PENDING_MESSAGES | 存在 pending 正文;先明确处理,不清空或重投 |
|
|
645
|
+
| SESSION_OPERATION_IN_PROGRESS | 同 ID 正在操作或原启动未决 |
|
|
646
|
+
| SESSION_NATIVE_OPERATION_IN_PROGRESS | 同原生身份有其它描述的在途/持久未决操作 |
|
|
647
|
+
| SESSION_SCOPE_CONFLICT | 终端还属于其它组;管理员先处理归属 |
|
|
648
|
+
| SESSION_IDENTITY_MISMATCH | 固定终端/pane/PID/布局或 shell 证明不符 |
|
|
649
|
+
| RESUME_CONFIGURATION_UNCONFIRMED | 旧记录缺少兼容确认;管理员核对当前指纹 |
|
|
650
|
+
| RESUME_CONFIGURATION_CHANGED | launcher 内容/路径或插件恢复语义变化;不自动采纳 |
|
|
651
|
+
| OPERATION_OUTCOME_UNCONFIRMED | 释放结果不明或承载重新出现;保留记录并核对 |
|
|
652
|
+
| SESSION_STORAGE_FULL | 恢复记录 journal 超过 64 MiB;停止新增操作并由管理员处理 |
|
|
653
|
+
| SESSION_RELEASE_FAILED | 未分类释放失败;不返回私有异常输出 |
|
|
654
|
+
| SESSION_RELEASED | 消息目标已释放;先显式精确 restore |
|
|
655
|
+
|
|
656
|
+
原有权限/组/目录、幂等、EXACT_RESUME_UNSUPPORTED、原生重复所有者、启动/绑定错误
|
|
657
|
+
继续适用;本节不改变这些错误的保护条件。
|
|
658
|
+
|
|
529
659
|
### 管理员恢复诊断(1.4.305)
|
|
530
660
|
|
|
531
661
|
`capabilities.session_restore_diagnostics=true` 表示服务支持持久恢复诊断。
|
|
@@ -586,6 +716,11 @@ no-op restore 不创建新检查。报告跟随会话保存并可在服务重启
|
|
|
586
716
|
|
|
587
717
|
## 验收边界
|
|
588
718
|
|
|
719
|
+
本版维护机验证:共享释放协议、真实 Unix socket 权限/生命周期事件、零入队拒绝、
|
|
720
|
+
同键并发/旧代次/异常释放/重启对账、配置内容变化与旧记录显式确认、原消息关联;
|
|
721
|
+
隔离真实 tmux 的实际销毁与原逻辑 ID 重建。本机 fixture 不等于私有 TraeX 验收,
|
|
722
|
+
本版真实 TraeX 的零承载→同 UUID→真人原话题往返仍待使用方升级后验证。
|
|
723
|
+
|
|
589
724
|
维护机验证:真实 Unix socket 主动推送/断线重放/撤销、journal 重载与源记录补偿、
|
|
590
725
|
权限与幂等隔离、accepted 上咨询/关联答复、修订后的新结果、原 profile 恢复和
|
|
591
726
|
实际 shell 参数透传。适配器测试不冒充真实 Agent 的原生会话验收。
|
package/package.json
CHANGED
package/runtime-manifest.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"packageName": "termdock",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.309",
|
|
5
5
|
"runtimeProtocolVersion": 1,
|
|
6
6
|
"minimumDesktopVersion": "1.4.46",
|
|
7
7
|
"nodeMajor": 22,
|
|
8
8
|
"dependencyHash": "sha256-E9GDa4nrCXADRXdyPKufusZt6IcCr4m2mGdx5h34KMQ=",
|
|
9
|
-
"serverBundleHash": "sha256-
|
|
9
|
+
"serverBundleHash": "sha256-3rATyPEt6ztAvF1p3T03N1mo2A1qIKkVUePIG0xc53w=",
|
|
10
10
|
"clientBundleHash": "sha256-Yh0fz4oTrM6A2pNHLYx+Y67/R7Wfa84cFlIPMrwAsfE=",
|
|
11
11
|
"entrypoint": "dist/server/cli.js",
|
|
12
12
|
"clientEntrypoint": "dist/client/index.html"
|