@clinebot/core 0.0.6 → 0.0.10
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/agents/hooks-config-loader.d.ts +1 -0
- package/dist/auth/cline.d.ts +2 -0
- package/dist/auth/codex.d.ts +5 -1
- package/dist/auth/oca.d.ts +7 -1
- package/dist/auth/types.d.ts +2 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.node.d.ts +2 -0
- package/dist/index.node.js +164 -162
- package/dist/input/mention-enricher.d.ts +1 -0
- package/dist/providers/local-provider-service.d.ts +1 -1
- package/dist/runtime/session-runtime.d.ts +1 -1
- package/dist/session/default-session-manager.d.ts +13 -17
- package/dist/session/rpc-spawn-lease.d.ts +7 -0
- package/dist/session/runtime-oauth-token-manager.d.ts +4 -2
- package/dist/session/session-agent-events.d.ts +15 -0
- package/dist/session/session-config-builder.d.ts +13 -0
- package/dist/session/session-manager.d.ts +2 -2
- package/dist/session/session-team-coordination.d.ts +12 -0
- package/dist/session/session-telemetry.d.ts +9 -0
- package/dist/session/unified-session-persistence-service.d.ts +12 -16
- package/dist/session/utils/helpers.d.ts +1 -1
- package/dist/session/utils/types.d.ts +1 -1
- package/dist/storage/provider-settings-legacy-migration.d.ts +25 -0
- package/dist/telemetry/core-events.d.ts +122 -0
- package/dist/tools/definitions.d.ts +1 -1
- package/dist/tools/executors/file-read.d.ts +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/presets.d.ts +1 -1
- package/dist/tools/schemas.d.ts +48 -11
- package/dist/tools/types.d.ts +3 -3
- package/dist/types/config.d.ts +1 -1
- package/dist/types/events.d.ts +1 -1
- package/dist/types/provider-settings.d.ts +4 -4
- package/dist/types.d.ts +1 -1
- package/package.json +4 -3
- package/src/agents/hooks-config-loader.ts +2 -0
- package/src/auth/cline.ts +35 -1
- package/src/auth/codex.ts +27 -2
- package/src/auth/oca.ts +31 -4
- package/src/auth/types.ts +3 -0
- package/src/index.node.ts +4 -0
- package/src/index.ts +27 -0
- package/src/input/file-indexer.test.ts +40 -0
- package/src/input/file-indexer.ts +21 -0
- package/src/input/mention-enricher.test.ts +3 -0
- package/src/input/mention-enricher.ts +3 -0
- package/src/providers/local-provider-service.ts +6 -7
- package/src/runtime/hook-file-hooks.test.ts +51 -1
- package/src/runtime/hook-file-hooks.ts +91 -11
- package/src/runtime/session-runtime.ts +1 -1
- package/src/session/default-session-manager.e2e.test.ts +2 -1
- package/src/session/default-session-manager.ts +367 -601
- package/src/session/rpc-spawn-lease.test.ts +49 -0
- package/src/session/rpc-spawn-lease.ts +122 -0
- package/src/session/runtime-oauth-token-manager.ts +21 -14
- package/src/session/session-agent-events.ts +159 -0
- package/src/session/session-config-builder.ts +111 -0
- package/src/session/session-graph.ts +2 -0
- package/src/session/session-host.ts +21 -0
- package/src/session/session-manager.ts +2 -2
- package/src/session/session-team-coordination.ts +198 -0
- package/src/session/session-telemetry.ts +95 -0
- package/src/session/unified-session-persistence-service.test.ts +81 -0
- package/src/session/unified-session-persistence-service.ts +470 -469
- package/src/session/utils/helpers.ts +1 -1
- package/src/session/utils/types.ts +1 -1
- package/src/storage/provider-settings-legacy-migration.test.ts +133 -1
- package/src/storage/provider-settings-legacy-migration.ts +63 -11
- package/src/telemetry/core-events.ts +344 -0
- package/src/tools/definitions.test.ts +203 -36
- package/src/tools/definitions.ts +66 -28
- package/src/tools/executors/editor.test.ts +35 -0
- package/src/tools/executors/editor.ts +33 -46
- package/src/tools/executors/file-read.test.ts +29 -5
- package/src/tools/executors/file-read.ts +17 -6
- package/src/tools/index.ts +2 -0
- package/src/tools/presets.ts +1 -1
- package/src/tools/schemas.ts +88 -38
- package/src/tools/types.ts +7 -3
- package/src/types/config.ts +1 -1
- package/src/types/events.ts +6 -1
- package/src/types/provider-settings.ts +6 -6
- package/src/types.ts +1 -1
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
SubAgentEndContext,
|
|
10
10
|
SubAgentStartContext,
|
|
11
11
|
} from "@clinebot/agents";
|
|
12
|
-
import type {
|
|
12
|
+
import type { LlmsProviders } from "@clinebot/llms";
|
|
13
13
|
import { normalizeUserInput, resolveRootSessionId } from "@clinebot/shared";
|
|
14
14
|
import { nanoid } from "nanoid";
|
|
15
15
|
import { z } from "zod";
|
|
@@ -32,6 +32,9 @@ import type {
|
|
|
32
32
|
} from "./session-service";
|
|
33
33
|
|
|
34
34
|
const SUBSESSION_SOURCE = "cli_subagent";
|
|
35
|
+
const MAX_TITLE_LENGTH = 120;
|
|
36
|
+
const OCC_MAX_RETRIES = 4;
|
|
37
|
+
|
|
35
38
|
const SpawnAgentInputSchema = z
|
|
36
39
|
.object({
|
|
37
40
|
task: z.string().optional(),
|
|
@@ -39,67 +42,86 @@ const SpawnAgentInputSchema = z
|
|
|
39
42
|
})
|
|
40
43
|
.passthrough();
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
// ── Metadata helpers ──────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function stringifyMetadata(
|
|
43
48
|
metadata: Record<string, unknown> | null | undefined,
|
|
44
49
|
): string | null {
|
|
45
|
-
if (!metadata || Object.keys(metadata).length === 0)
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
50
|
+
if (!metadata || Object.keys(metadata).length === 0) return null;
|
|
48
51
|
return JSON.stringify(metadata);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
function
|
|
54
|
+
function parseMetadataJson(
|
|
55
|
+
raw: string | null | undefined,
|
|
56
|
+
): Record<string, unknown> | undefined {
|
|
57
|
+
const trimmed = raw?.trim();
|
|
58
|
+
if (!trimmed) return undefined;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
61
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
62
|
+
return parsed as Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Ignore malformed metadata payloads.
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeTitle(title?: string | null): string | undefined {
|
|
52
71
|
const trimmed = title?.trim();
|
|
53
|
-
return trimmed ? trimmed.slice(0,
|
|
72
|
+
return trimmed ? trimmed.slice(0, MAX_TITLE_LENGTH) : undefined;
|
|
54
73
|
}
|
|
55
74
|
|
|
56
|
-
function
|
|
57
|
-
prompt
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
if (!normalizedPrompt) {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
const firstLine = normalizedPrompt.split("\n")[0]?.trim();
|
|
64
|
-
return normalizeSessionTitle(firstLine);
|
|
75
|
+
function deriveTitleFromPrompt(prompt?: string | null): string | undefined {
|
|
76
|
+
const normalized = normalizeUserInput(prompt ?? "").trim();
|
|
77
|
+
if (!normalized) return undefined;
|
|
78
|
+
return normalizeTitle(normalized.split("\n")[0]?.trim());
|
|
65
79
|
}
|
|
66
80
|
|
|
67
|
-
|
|
81
|
+
/** Strip invalid title from metadata, drop empty objects. */
|
|
82
|
+
function sanitizeMetadata(
|
|
68
83
|
metadata: Record<string, unknown> | null | undefined,
|
|
69
84
|
): Record<string, unknown> | undefined {
|
|
70
|
-
if (!metadata)
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
85
|
+
if (!metadata) return undefined;
|
|
73
86
|
const next = { ...metadata };
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
delete next.title;
|
|
80
|
-
}
|
|
87
|
+
const title = normalizeTitle(
|
|
88
|
+
typeof next.title === "string" ? next.title : undefined,
|
|
89
|
+
);
|
|
90
|
+
if (title) {
|
|
91
|
+
next.title = title;
|
|
81
92
|
} else {
|
|
82
93
|
delete next.title;
|
|
83
94
|
}
|
|
84
95
|
return Object.keys(next).length > 0 ? next : undefined;
|
|
85
96
|
}
|
|
86
97
|
|
|
87
|
-
|
|
98
|
+
/** Resolve title from explicit title, prompt, or existing metadata. */
|
|
99
|
+
function resolveMetadataWithTitle(input: {
|
|
88
100
|
metadata?: Record<string, unknown> | null;
|
|
89
101
|
title?: string | null;
|
|
90
102
|
prompt?: string | null;
|
|
91
103
|
}): Record<string, unknown> | undefined {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
104
|
+
const base = sanitizeMetadata(input.metadata) ?? {};
|
|
105
|
+
const title =
|
|
94
106
|
input.title !== undefined
|
|
95
|
-
?
|
|
96
|
-
:
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
return Object.keys(next).length > 0 ? next : undefined;
|
|
107
|
+
? normalizeTitle(input.title)
|
|
108
|
+
: deriveTitleFromPrompt(input.prompt);
|
|
109
|
+
if (title) base.title = title;
|
|
110
|
+
return Object.keys(base).length > 0 ? base : undefined;
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
// ── File helpers ──────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function writeEmptyMessagesFile(path: string, startedAt: string): void {
|
|
116
|
+
writeFileSync(
|
|
117
|
+
path,
|
|
118
|
+
`${JSON.stringify({ version: 1, updated_at: startedAt, messages: [] }, null, 2)}\n`,
|
|
119
|
+
"utf8",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Interfaces ────────────────────────────────────────────────────────
|
|
124
|
+
|
|
103
125
|
export interface PersistedSessionUpdateInput {
|
|
104
126
|
sessionId: string;
|
|
105
127
|
expectedStatusLock?: number;
|
|
@@ -141,47 +163,108 @@ export interface SessionPersistenceAdapter {
|
|
|
141
163
|
): Promise<string | undefined>;
|
|
142
164
|
}
|
|
143
165
|
|
|
166
|
+
// ── Service ───────────────────────────────────────────────────────────
|
|
167
|
+
|
|
144
168
|
export class UnifiedSessionPersistenceService {
|
|
145
169
|
private readonly teamTaskSessionsByAgent = new Map<string, string[]>();
|
|
146
170
|
protected readonly artifacts: SessionArtifacts;
|
|
171
|
+
private static readonly STALE_REASON = "failed_external_process_exit";
|
|
172
|
+
private static readonly STALE_SOURCE = "stale_session_reconciler";
|
|
147
173
|
|
|
148
174
|
constructor(private readonly adapter: SessionPersistenceAdapter) {
|
|
149
175
|
this.artifacts = new SessionArtifacts(() => this.ensureSessionsDir());
|
|
150
176
|
}
|
|
151
177
|
|
|
152
|
-
private teamTaskQueueKey(rootSessionId: string, agentId: string): string {
|
|
153
|
-
return `${rootSessionId}::${agentId}`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
178
|
ensureSessionsDir(): string {
|
|
157
179
|
return this.adapter.ensureSessionsDir();
|
|
158
180
|
}
|
|
159
181
|
|
|
160
|
-
|
|
161
|
-
|
|
182
|
+
// ── Manifest I/O ──────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
private writeManifestFile(
|
|
185
|
+
manifestPath: string,
|
|
186
|
+
manifest: SessionManifest,
|
|
187
|
+
): void {
|
|
188
|
+
writeFileSync(
|
|
189
|
+
manifestPath,
|
|
190
|
+
`${JSON.stringify(SessionManifestSchema.parse(manifest), null, 2)}\n`,
|
|
191
|
+
"utf8",
|
|
192
|
+
);
|
|
162
193
|
}
|
|
163
194
|
|
|
164
|
-
|
|
165
|
-
|
|
195
|
+
writeSessionManifest(manifestPath: string, manifest: SessionManifest): void {
|
|
196
|
+
this.writeManifestFile(manifestPath, manifest);
|
|
166
197
|
}
|
|
167
198
|
|
|
168
|
-
private
|
|
169
|
-
|
|
199
|
+
private readManifestFile(sessionId: string): {
|
|
200
|
+
path: string;
|
|
201
|
+
manifest?: SessionManifest;
|
|
202
|
+
} {
|
|
203
|
+
const manifestPath = this.artifacts.sessionManifestPath(sessionId, false);
|
|
204
|
+
if (!existsSync(manifestPath)) return { path: manifestPath };
|
|
205
|
+
try {
|
|
206
|
+
return {
|
|
207
|
+
path: manifestPath,
|
|
208
|
+
manifest: SessionManifestSchema.parse(
|
|
209
|
+
JSON.parse(readFileSync(manifestPath, "utf8")) as SessionManifest,
|
|
210
|
+
),
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
return { path: manifestPath };
|
|
214
|
+
}
|
|
170
215
|
}
|
|
171
216
|
|
|
172
|
-
private
|
|
173
|
-
|
|
217
|
+
private buildManifestFromRow(
|
|
218
|
+
row: SessionRowShape,
|
|
219
|
+
overrides?: {
|
|
220
|
+
status?: SessionStatus;
|
|
221
|
+
endedAt?: string | null;
|
|
222
|
+
exitCode?: number | null;
|
|
223
|
+
metadata?: Record<string, unknown>;
|
|
224
|
+
},
|
|
225
|
+
): SessionManifest {
|
|
226
|
+
return SessionManifestSchema.parse({
|
|
227
|
+
version: 1,
|
|
228
|
+
session_id: row.session_id,
|
|
229
|
+
source: row.source,
|
|
230
|
+
pid: row.pid,
|
|
231
|
+
started_at: row.started_at,
|
|
232
|
+
ended_at: overrides?.endedAt ?? row.ended_at ?? undefined,
|
|
233
|
+
exit_code: overrides?.exitCode ?? row.exit_code ?? undefined,
|
|
234
|
+
status: overrides?.status ?? row.status,
|
|
235
|
+
interactive: row.interactive === 1,
|
|
236
|
+
provider: row.provider,
|
|
237
|
+
model: row.model,
|
|
238
|
+
cwd: row.cwd,
|
|
239
|
+
workspace_root: row.workspace_root,
|
|
240
|
+
team_name: row.team_name ?? undefined,
|
|
241
|
+
enable_tools: row.enable_tools === 1,
|
|
242
|
+
enable_spawn: row.enable_spawn === 1,
|
|
243
|
+
enable_teams: row.enable_teams === 1,
|
|
244
|
+
prompt: row.prompt ?? undefined,
|
|
245
|
+
metadata: overrides?.metadata ?? parseMetadataJson(row.metadata_json),
|
|
246
|
+
messages_path: row.messages_path ?? undefined,
|
|
247
|
+
});
|
|
174
248
|
}
|
|
175
249
|
|
|
176
|
-
|
|
250
|
+
// ── Path resolution ───────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
private async resolveArtifactPath(
|
|
177
253
|
sessionId: string,
|
|
178
254
|
kind: "transcript_path" | "hook_path" | "messages_path",
|
|
179
|
-
|
|
255
|
+
fallback: (id: string) => string,
|
|
256
|
+
): Promise<string> {
|
|
180
257
|
const row = await this.adapter.getSession(sessionId);
|
|
181
258
|
const value = row?.[kind];
|
|
182
259
|
return typeof value === "string" && value.trim().length > 0
|
|
183
260
|
? value
|
|
184
|
-
:
|
|
261
|
+
: fallback(sessionId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Team task queue ───────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
private teamTaskQueueKey(rootSessionId: string, agentId: string): string {
|
|
267
|
+
return `${rootSessionId}::${agentId}`;
|
|
185
268
|
}
|
|
186
269
|
|
|
187
270
|
private activeTeamTaskSessionId(
|
|
@@ -191,115 +274,27 @@ export class UnifiedSessionPersistenceService {
|
|
|
191
274
|
const queue = this.teamTaskSessionsByAgent.get(
|
|
192
275
|
this.teamTaskQueueKey(rootSessionId, parentAgentId),
|
|
193
276
|
);
|
|
194
|
-
|
|
195
|
-
return undefined;
|
|
196
|
-
}
|
|
197
|
-
return queue[queue.length - 1];
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private subagentArtifactPaths(
|
|
201
|
-
rootSessionId: string,
|
|
202
|
-
sessionId: string,
|
|
203
|
-
parentAgentId: string,
|
|
204
|
-
subAgentId: string,
|
|
205
|
-
): {
|
|
206
|
-
transcriptPath: string;
|
|
207
|
-
hookPath: string;
|
|
208
|
-
messagesPath: string;
|
|
209
|
-
} {
|
|
210
|
-
return this.artifacts.subagentArtifactPaths(
|
|
211
|
-
sessionId,
|
|
212
|
-
subAgentId,
|
|
213
|
-
this.activeTeamTaskSessionId(rootSessionId, parentAgentId),
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
private writeSessionManifestFile(
|
|
218
|
-
manifestPath: string,
|
|
219
|
-
manifest: SessionManifest,
|
|
220
|
-
): void {
|
|
221
|
-
const parsedManifest = SessionManifestSchema.parse(manifest);
|
|
222
|
-
writeFileSync(
|
|
223
|
-
manifestPath,
|
|
224
|
-
`${JSON.stringify(parsedManifest, null, 2)}\n`,
|
|
225
|
-
"utf8",
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private readSessionManifestFile(sessionId: string): {
|
|
230
|
-
path: string;
|
|
231
|
-
manifest?: SessionManifest;
|
|
232
|
-
} {
|
|
233
|
-
const manifestPath = this.sessionManifestPath(sessionId, false);
|
|
234
|
-
if (!existsSync(manifestPath)) {
|
|
235
|
-
return { path: manifestPath };
|
|
236
|
-
}
|
|
237
|
-
try {
|
|
238
|
-
const manifest = SessionManifestSchema.parse(
|
|
239
|
-
JSON.parse(readFileSync(manifestPath, "utf8")) as SessionManifest,
|
|
240
|
-
);
|
|
241
|
-
return { path: manifestPath, manifest };
|
|
242
|
-
} catch {
|
|
243
|
-
return { path: manifestPath };
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private applyResolvedTitleToRow(row: SessionRowShape): SessionRowShape {
|
|
248
|
-
const existingMetadata =
|
|
249
|
-
typeof row.metadata_json === "string" &&
|
|
250
|
-
row.metadata_json.trim().length > 0
|
|
251
|
-
? (() => {
|
|
252
|
-
try {
|
|
253
|
-
const parsed = JSON.parse(row.metadata_json) as unknown;
|
|
254
|
-
if (
|
|
255
|
-
parsed &&
|
|
256
|
-
typeof parsed === "object" &&
|
|
257
|
-
!Array.isArray(parsed)
|
|
258
|
-
) {
|
|
259
|
-
return parsed as Record<string, unknown>;
|
|
260
|
-
}
|
|
261
|
-
} catch {
|
|
262
|
-
// Ignore malformed metadata payloads.
|
|
263
|
-
}
|
|
264
|
-
return undefined;
|
|
265
|
-
})()
|
|
266
|
-
: undefined;
|
|
267
|
-
const sanitizedMetadata = normalizeMetadataForStorage(existingMetadata);
|
|
268
|
-
const { manifest } = this.readSessionManifestFile(row.session_id);
|
|
269
|
-
const manifestTitle = normalizeSessionTitle(
|
|
270
|
-
typeof manifest?.metadata?.title === "string"
|
|
271
|
-
? (manifest.metadata.title as string)
|
|
272
|
-
: undefined,
|
|
273
|
-
);
|
|
274
|
-
const resolvedMetadata = manifestTitle
|
|
275
|
-
? {
|
|
276
|
-
...(sanitizedMetadata ?? {}),
|
|
277
|
-
title: manifestTitle,
|
|
278
|
-
}
|
|
279
|
-
: sanitizedMetadata;
|
|
280
|
-
return {
|
|
281
|
-
...row,
|
|
282
|
-
metadata_json: stringifyMetadataJson(resolvedMetadata),
|
|
283
|
-
};
|
|
277
|
+
return queue?.at(-1);
|
|
284
278
|
}
|
|
285
279
|
|
|
286
|
-
|
|
287
|
-
return `${Date.now()}_${nanoid(5)}`;
|
|
288
|
-
}
|
|
280
|
+
// ── Root session ──────────────────────────────────────────────────
|
|
289
281
|
|
|
290
282
|
async createRootSessionWithArtifacts(
|
|
291
283
|
input: CreateRootSessionWithArtifactsInput,
|
|
292
284
|
): Promise<RootSessionArtifacts> {
|
|
293
285
|
const startedAt = input.startedAt ?? nowIso();
|
|
294
|
-
const
|
|
286
|
+
const providedId = input.sessionId.trim();
|
|
295
287
|
const sessionId =
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
const
|
|
288
|
+
providedId.length > 0 ? providedId : `${Date.now()}_${nanoid(5)}`;
|
|
289
|
+
const transcriptPath = this.artifacts.sessionTranscriptPath(sessionId);
|
|
290
|
+
const hookPath = this.artifacts.sessionHookPath(sessionId);
|
|
291
|
+
const messagesPath = this.artifacts.sessionMessagesPath(sessionId);
|
|
292
|
+
const manifestPath = this.artifacts.sessionManifestPath(sessionId);
|
|
293
|
+
|
|
294
|
+
const metadata = resolveMetadataWithTitle({
|
|
295
|
+
metadata: input.metadata,
|
|
296
|
+
prompt: input.prompt,
|
|
297
|
+
});
|
|
303
298
|
const manifest = SessionManifestSchema.parse({
|
|
304
299
|
version: 1,
|
|
305
300
|
session_id: sessionId,
|
|
@@ -317,13 +312,9 @@ export class UnifiedSessionPersistenceService {
|
|
|
317
312
|
enable_spawn: input.enableSpawn,
|
|
318
313
|
enable_teams: input.enableTeams,
|
|
319
314
|
prompt: input.prompt?.trim() || undefined,
|
|
320
|
-
metadata
|
|
321
|
-
metadata: input.metadata,
|
|
322
|
-
prompt: input.prompt,
|
|
323
|
-
}),
|
|
315
|
+
metadata,
|
|
324
316
|
messages_path: messagesPath,
|
|
325
317
|
});
|
|
326
|
-
const storedMetadata = normalizeMetadataForStorage(manifest.metadata);
|
|
327
318
|
|
|
328
319
|
await this.adapter.upsertSession({
|
|
329
320
|
session_id: sessionId,
|
|
@@ -349,42 +340,30 @@ export class UnifiedSessionPersistenceService {
|
|
|
349
340
|
conversation_id: null,
|
|
350
341
|
is_subagent: 0,
|
|
351
342
|
prompt: manifest.prompt ?? null,
|
|
352
|
-
metadata_json:
|
|
343
|
+
metadata_json: stringifyMetadata(sanitizeMetadata(manifest.metadata)),
|
|
353
344
|
transcript_path: transcriptPath,
|
|
354
345
|
hook_path: hookPath,
|
|
355
346
|
messages_path: messagesPath,
|
|
356
347
|
updated_at: nowIso(),
|
|
357
348
|
});
|
|
358
349
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
"utf8",
|
|
363
|
-
);
|
|
364
|
-
this.writeSessionManifestFile(manifestPath, manifest);
|
|
365
|
-
return {
|
|
366
|
-
manifestPath,
|
|
367
|
-
transcriptPath,
|
|
368
|
-
hookPath,
|
|
369
|
-
messagesPath,
|
|
370
|
-
manifest,
|
|
371
|
-
};
|
|
350
|
+
writeEmptyMessagesFile(messagesPath, startedAt);
|
|
351
|
+
this.writeManifestFile(manifestPath, manifest);
|
|
352
|
+
return { manifestPath, transcriptPath, hookPath, messagesPath, manifest };
|
|
372
353
|
}
|
|
373
354
|
|
|
374
|
-
|
|
375
|
-
this.writeSessionManifestFile(manifestPath, manifest);
|
|
376
|
-
}
|
|
355
|
+
// ── Session status updates ────────────────────────────────────────
|
|
377
356
|
|
|
378
357
|
async updateSessionStatus(
|
|
379
358
|
sessionId: string,
|
|
380
359
|
status: SessionStatus,
|
|
381
360
|
exitCode?: number | null,
|
|
382
361
|
): Promise<{ updated: boolean; endedAt?: string }> {
|
|
383
|
-
for (let attempt = 0; attempt <
|
|
362
|
+
for (let attempt = 0; attempt < OCC_MAX_RETRIES; attempt++) {
|
|
384
363
|
const row = await this.adapter.getSession(sessionId);
|
|
385
|
-
if (!row || typeof row.status_lock !== "number")
|
|
364
|
+
if (!row || typeof row.status_lock !== "number")
|
|
386
365
|
return { updated: false };
|
|
387
|
-
|
|
366
|
+
|
|
388
367
|
const endedAt = nowIso();
|
|
389
368
|
const changed = await this.adapter.updateSession({
|
|
390
369
|
sessionId,
|
|
@@ -409,192 +388,183 @@ export class UnifiedSessionPersistenceService {
|
|
|
409
388
|
metadata?: Record<string, unknown> | null;
|
|
410
389
|
title?: string | null;
|
|
411
390
|
}): Promise<{ updated: boolean }> {
|
|
412
|
-
for (let attempt = 0; attempt <
|
|
391
|
+
for (let attempt = 0; attempt < OCC_MAX_RETRIES; attempt++) {
|
|
413
392
|
const row = await this.adapter.getSession(input.sessionId);
|
|
414
|
-
if (!row || typeof row.status_lock !== "number")
|
|
393
|
+
if (!row || typeof row.status_lock !== "number")
|
|
415
394
|
return { updated: false };
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
try {
|
|
427
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
428
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
429
|
-
return normalizeMetadataForStorage(
|
|
430
|
-
parsed as Record<string, unknown>,
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
} catch {
|
|
434
|
-
// Ignore malformed metadata payloads.
|
|
435
|
-
}
|
|
436
|
-
return undefined;
|
|
437
|
-
})();
|
|
438
|
-
const existingTitle = normalizeSessionTitle(
|
|
439
|
-
typeof existingMetadata?.title === "string"
|
|
440
|
-
? (existingMetadata.title as string)
|
|
395
|
+
|
|
396
|
+
const existingMeta = parseMetadataJson(row.metadata_json);
|
|
397
|
+
const baseMeta =
|
|
398
|
+
input.metadata !== undefined
|
|
399
|
+
? (sanitizeMetadata(input.metadata) ?? {})
|
|
400
|
+
: (sanitizeMetadata(existingMeta) ?? {});
|
|
401
|
+
|
|
402
|
+
const existingTitle = normalizeTitle(
|
|
403
|
+
typeof existingMeta?.title === "string"
|
|
404
|
+
? (existingMeta.title as string)
|
|
441
405
|
: undefined,
|
|
442
406
|
);
|
|
443
407
|
const nextTitle =
|
|
444
408
|
input.title !== undefined
|
|
445
|
-
?
|
|
409
|
+
? normalizeTitle(input.title)
|
|
446
410
|
: input.prompt !== undefined
|
|
447
|
-
?
|
|
411
|
+
? deriveTitleFromPrompt(input.prompt)
|
|
448
412
|
: existingTitle;
|
|
449
|
-
|
|
450
|
-
input.metadata !== undefined
|
|
451
|
-
? { ...(sanitizedMetadata ?? {}) }
|
|
452
|
-
: { ...(existingMetadata ?? {}) };
|
|
413
|
+
|
|
453
414
|
if (nextTitle) {
|
|
454
|
-
|
|
415
|
+
baseMeta.title = nextTitle;
|
|
455
416
|
} else {
|
|
456
|
-
delete
|
|
417
|
+
delete baseMeta.title;
|
|
457
418
|
}
|
|
419
|
+
|
|
420
|
+
const hasMetadataChange =
|
|
421
|
+
input.metadata !== undefined ||
|
|
422
|
+
input.prompt !== undefined ||
|
|
423
|
+
input.title !== undefined;
|
|
424
|
+
|
|
458
425
|
const changed = await this.adapter.updateSession({
|
|
459
426
|
sessionId: input.sessionId,
|
|
460
427
|
prompt: input.prompt,
|
|
461
|
-
metadataJson:
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
input.title === undefined
|
|
465
|
-
? undefined
|
|
466
|
-
: stringifyMetadataJson(nextMetadata),
|
|
428
|
+
metadataJson: hasMetadataChange
|
|
429
|
+
? stringifyMetadata(baseMeta)
|
|
430
|
+
: undefined,
|
|
467
431
|
title: nextTitle,
|
|
468
432
|
expectedStatusLock: row.status_lock,
|
|
469
433
|
});
|
|
470
|
-
if (!changed.updated)
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
const { path: manifestPath, manifest } = this.readSessionManifestFile(
|
|
434
|
+
if (!changed.updated) continue;
|
|
435
|
+
|
|
436
|
+
const { path: manifestPath, manifest } = this.readManifestFile(
|
|
474
437
|
input.sessionId,
|
|
475
438
|
);
|
|
476
439
|
if (manifest) {
|
|
477
440
|
if (input.prompt !== undefined) {
|
|
478
441
|
manifest.prompt = input.prompt ?? undefined;
|
|
479
442
|
}
|
|
480
|
-
const
|
|
443
|
+
const manifestMeta =
|
|
481
444
|
input.metadata !== undefined
|
|
482
|
-
?
|
|
483
|
-
:
|
|
484
|
-
if (nextTitle)
|
|
485
|
-
nextMetadata.title = nextTitle;
|
|
486
|
-
}
|
|
445
|
+
? (sanitizeMetadata(input.metadata) ?? {})
|
|
446
|
+
: (sanitizeMetadata(manifest.metadata) ?? {});
|
|
447
|
+
if (nextTitle) manifestMeta.title = nextTitle;
|
|
487
448
|
manifest.metadata =
|
|
488
|
-
Object.keys(
|
|
489
|
-
this.
|
|
449
|
+
Object.keys(manifestMeta).length > 0 ? manifestMeta : undefined;
|
|
450
|
+
this.writeManifestFile(manifestPath, manifest);
|
|
490
451
|
}
|
|
491
452
|
return { updated: true };
|
|
492
453
|
}
|
|
493
454
|
return { updated: false };
|
|
494
455
|
}
|
|
495
456
|
|
|
457
|
+
// ── Spawn queue ───────────────────────────────────────────────────
|
|
458
|
+
|
|
496
459
|
async queueSpawnRequest(event: HookEventPayload): Promise<void> {
|
|
497
|
-
if (event.hookName !== "tool_call" || event.parent_agent_id !== null)
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
if (event.tool_call?.name !== "spawn_agent") {
|
|
460
|
+
if (event.hookName !== "tool_call" || event.parent_agent_id !== null)
|
|
501
461
|
return;
|
|
502
|
-
|
|
462
|
+
if (event.tool_call?.name !== "spawn_agent") return;
|
|
463
|
+
|
|
503
464
|
const rootSessionId = resolveRootSessionId(event.sessionContext);
|
|
504
|
-
if (!rootSessionId)
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const parsedInput = SpawnAgentInputSchema.safeParse(event.tool_call.input);
|
|
508
|
-
const task = parsedInput.success ? parsedInput.data.task : undefined;
|
|
509
|
-
const systemPrompt = parsedInput.success
|
|
510
|
-
? parsedInput.data.systemPrompt
|
|
511
|
-
: undefined;
|
|
465
|
+
if (!rootSessionId) return;
|
|
466
|
+
|
|
467
|
+
const parsed = SpawnAgentInputSchema.safeParse(event.tool_call.input);
|
|
512
468
|
await this.adapter.enqueueSpawnRequest({
|
|
513
469
|
rootSessionId,
|
|
514
470
|
parentAgentId: event.agent_id,
|
|
515
|
-
task,
|
|
516
|
-
systemPrompt,
|
|
471
|
+
task: parsed.success ? parsed.data.task : undefined,
|
|
472
|
+
systemPrompt: parsed.success ? parsed.data.systemPrompt : undefined,
|
|
517
473
|
});
|
|
518
474
|
}
|
|
519
475
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
476
|
+
// ── Subagent sessions ─────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
private buildSubsessionRow(
|
|
479
|
+
root: SessionRowShape,
|
|
480
|
+
opts: {
|
|
481
|
+
sessionId: string;
|
|
482
|
+
parentSessionId: string;
|
|
483
|
+
parentAgentId: string;
|
|
484
|
+
agentId: string;
|
|
485
|
+
conversationId?: string | null;
|
|
486
|
+
prompt: string;
|
|
487
|
+
startedAt: string;
|
|
488
|
+
transcriptPath: string;
|
|
489
|
+
hookPath: string;
|
|
490
|
+
messagesPath: string;
|
|
491
|
+
},
|
|
492
|
+
): SessionRowShape {
|
|
493
|
+
return {
|
|
494
|
+
session_id: opts.sessionId,
|
|
495
|
+
source: SUBSESSION_SOURCE,
|
|
496
|
+
pid: process.ppid,
|
|
497
|
+
started_at: opts.startedAt,
|
|
498
|
+
ended_at: null,
|
|
499
|
+
exit_code: null,
|
|
500
|
+
status: "running",
|
|
501
|
+
status_lock: 0,
|
|
502
|
+
interactive: 0,
|
|
503
|
+
provider: root.provider,
|
|
504
|
+
model: root.model,
|
|
505
|
+
cwd: root.cwd,
|
|
506
|
+
workspace_root: root.workspace_root,
|
|
507
|
+
team_name: root.team_name ?? null,
|
|
508
|
+
enable_tools: root.enable_tools,
|
|
509
|
+
enable_spawn: root.enable_spawn,
|
|
510
|
+
enable_teams: root.enable_teams,
|
|
511
|
+
parent_session_id: opts.parentSessionId,
|
|
512
|
+
parent_agent_id: opts.parentAgentId,
|
|
513
|
+
agent_id: opts.agentId,
|
|
514
|
+
conversation_id: opts.conversationId ?? null,
|
|
515
|
+
is_subagent: 1,
|
|
516
|
+
prompt: opts.prompt,
|
|
517
|
+
metadata_json: stringifyMetadata(
|
|
518
|
+
resolveMetadataWithTitle({ prompt: opts.prompt }),
|
|
519
|
+
),
|
|
520
|
+
transcript_path: opts.transcriptPath,
|
|
521
|
+
hook_path: opts.hookPath,
|
|
522
|
+
messages_path: opts.messagesPath,
|
|
523
|
+
updated_at: opts.startedAt,
|
|
524
|
+
};
|
|
532
525
|
}
|
|
533
526
|
|
|
534
527
|
async upsertSubagentSession(
|
|
535
528
|
input: UpsertSubagentInput,
|
|
536
529
|
): Promise<string | undefined> {
|
|
537
530
|
const rootSessionId = input.rootSessionId;
|
|
538
|
-
if (!rootSessionId)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return undefined;
|
|
544
|
-
}
|
|
531
|
+
if (!rootSessionId) return undefined;
|
|
532
|
+
|
|
533
|
+
const root = await this.adapter.getSession(rootSessionId);
|
|
534
|
+
if (!root) return undefined;
|
|
535
|
+
|
|
545
536
|
const sessionId = makeSubSessionId(rootSessionId, input.agentId);
|
|
546
537
|
const existing = await this.adapter.getSession(sessionId);
|
|
547
538
|
const startedAt = nowIso();
|
|
548
|
-
const artifactPaths = this.subagentArtifactPaths(
|
|
549
|
-
rootSessionId,
|
|
539
|
+
const artifactPaths = this.artifacts.subagentArtifactPaths(
|
|
550
540
|
sessionId,
|
|
551
|
-
input.parentAgentId,
|
|
552
541
|
input.agentId,
|
|
542
|
+
this.activeTeamTaskSessionId(rootSessionId, input.parentAgentId),
|
|
553
543
|
);
|
|
544
|
+
|
|
554
545
|
let prompt = input.prompt ?? existing?.prompt ?? undefined;
|
|
555
546
|
if (!prompt) {
|
|
556
547
|
prompt =
|
|
557
|
-
(await this.
|
|
558
|
-
|
|
548
|
+
(await this.adapter.claimSpawnRequest(
|
|
549
|
+
rootSessionId,
|
|
550
|
+
input.parentAgentId,
|
|
551
|
+
)) ?? `Subagent run by ${input.parentAgentId}`;
|
|
559
552
|
}
|
|
553
|
+
|
|
560
554
|
if (!existing) {
|
|
561
|
-
await this.adapter.upsertSession(
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
model: root.model,
|
|
573
|
-
cwd: root.cwd,
|
|
574
|
-
workspace_root: root.workspace_root,
|
|
575
|
-
team_name: root.team_name ?? null,
|
|
576
|
-
enable_tools: root.enable_tools,
|
|
577
|
-
enable_spawn: root.enable_spawn,
|
|
578
|
-
enable_teams: root.enable_teams,
|
|
579
|
-
parent_session_id: rootSessionId,
|
|
580
|
-
parent_agent_id: input.parentAgentId,
|
|
581
|
-
agent_id: input.agentId,
|
|
582
|
-
conversation_id: input.conversationId,
|
|
583
|
-
is_subagent: 1,
|
|
584
|
-
prompt,
|
|
585
|
-
metadata_json: stringifyMetadataJson(
|
|
586
|
-
metadataWithResolvedTitle({ prompt }),
|
|
587
|
-
),
|
|
588
|
-
transcript_path: artifactPaths.transcriptPath,
|
|
589
|
-
hook_path: artifactPaths.hookPath,
|
|
590
|
-
messages_path: artifactPaths.messagesPath,
|
|
591
|
-
updated_at: startedAt,
|
|
592
|
-
});
|
|
593
|
-
writeFileSync(
|
|
594
|
-
artifactPaths.messagesPath,
|
|
595
|
-
`${JSON.stringify({ version: 1, updated_at: startedAt, messages: [] }, null, 2)}\n`,
|
|
596
|
-
"utf8",
|
|
555
|
+
await this.adapter.upsertSession(
|
|
556
|
+
this.buildSubsessionRow(root, {
|
|
557
|
+
sessionId,
|
|
558
|
+
parentSessionId: rootSessionId,
|
|
559
|
+
parentAgentId: input.parentAgentId,
|
|
560
|
+
agentId: input.agentId,
|
|
561
|
+
conversationId: input.conversationId,
|
|
562
|
+
prompt,
|
|
563
|
+
startedAt,
|
|
564
|
+
...artifactPaths,
|
|
565
|
+
}),
|
|
597
566
|
);
|
|
567
|
+
writeEmptyMessagesFile(artifactPaths.messagesPath, startedAt);
|
|
598
568
|
return sessionId;
|
|
599
569
|
}
|
|
600
570
|
|
|
@@ -606,27 +576,9 @@ export class UnifiedSessionPersistenceService {
|
|
|
606
576
|
agentId: input.agentId,
|
|
607
577
|
conversationId: input.conversationId,
|
|
608
578
|
prompt: existing.prompt ?? prompt ?? null,
|
|
609
|
-
metadataJson:
|
|
610
|
-
|
|
611
|
-
metadata: (
|
|
612
|
-
const raw = existing.metadata_json?.trim();
|
|
613
|
-
if (!raw) {
|
|
614
|
-
return undefined;
|
|
615
|
-
}
|
|
616
|
-
try {
|
|
617
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
618
|
-
if (
|
|
619
|
-
parsed &&
|
|
620
|
-
typeof parsed === "object" &&
|
|
621
|
-
!Array.isArray(parsed)
|
|
622
|
-
) {
|
|
623
|
-
return parsed as Record<string, unknown>;
|
|
624
|
-
}
|
|
625
|
-
} catch {
|
|
626
|
-
// Ignore malformed metadata payloads.
|
|
627
|
-
}
|
|
628
|
-
return undefined;
|
|
629
|
-
})(),
|
|
579
|
+
metadataJson: stringifyMetadata(
|
|
580
|
+
resolveMetadataWithTitle({
|
|
581
|
+
metadata: parseMetadataJson(existing.metadata_json),
|
|
630
582
|
prompt: existing.prompt ?? prompt ?? null,
|
|
631
583
|
}),
|
|
632
584
|
),
|
|
@@ -638,13 +590,11 @@ export class UnifiedSessionPersistenceService {
|
|
|
638
590
|
async upsertSubagentSessionFromHook(
|
|
639
591
|
event: HookEventPayload,
|
|
640
592
|
): Promise<string | undefined> {
|
|
641
|
-
if (!event.parent_agent_id)
|
|
642
|
-
|
|
643
|
-
}
|
|
593
|
+
if (!event.parent_agent_id) return undefined;
|
|
594
|
+
|
|
644
595
|
const rootSessionId = resolveRootSessionId(event.sessionContext);
|
|
645
|
-
if (!rootSessionId)
|
|
646
|
-
|
|
647
|
-
}
|
|
596
|
+
if (!rootSessionId) return undefined;
|
|
597
|
+
|
|
648
598
|
if (event.hookName === "session_shutdown") {
|
|
649
599
|
const sessionId = makeSubSessionId(rootSessionId, event.agent_id);
|
|
650
600
|
const existing = await this.adapter.getSession(sessionId);
|
|
@@ -658,27 +608,34 @@ export class UnifiedSessionPersistenceService {
|
|
|
658
608
|
});
|
|
659
609
|
}
|
|
660
610
|
|
|
611
|
+
// ── Subagent audit / transcript ───────────────────────────────────
|
|
612
|
+
|
|
661
613
|
async appendSubagentHookAudit(
|
|
662
614
|
subSessionId: string,
|
|
663
615
|
event: HookEventPayload,
|
|
664
616
|
): Promise<void> {
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
this.sessionHookPath(
|
|
669
|
-
|
|
617
|
+
const path = await this.resolveArtifactPath(
|
|
618
|
+
subSessionId,
|
|
619
|
+
"hook_path",
|
|
620
|
+
(id) => this.artifacts.sessionHookPath(id),
|
|
621
|
+
);
|
|
622
|
+
appendFileSync(
|
|
623
|
+
path,
|
|
624
|
+
`${JSON.stringify({ ts: nowIso(), ...event })}\n`,
|
|
625
|
+
"utf8",
|
|
626
|
+
);
|
|
670
627
|
}
|
|
671
628
|
|
|
672
629
|
async appendSubagentTranscriptLine(
|
|
673
630
|
subSessionId: string,
|
|
674
631
|
line: string,
|
|
675
632
|
): Promise<void> {
|
|
676
|
-
if (!line.trim())
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
(
|
|
681
|
-
|
|
633
|
+
if (!line.trim()) return;
|
|
634
|
+
const path = await this.resolveArtifactPath(
|
|
635
|
+
subSessionId,
|
|
636
|
+
"transcript_path",
|
|
637
|
+
(id) => this.artifacts.sessionTranscriptPath(id),
|
|
638
|
+
);
|
|
682
639
|
appendFileSync(path, `${line}\n`, "utf8");
|
|
683
640
|
}
|
|
684
641
|
|
|
@@ -687,21 +644,23 @@ export class UnifiedSessionPersistenceService {
|
|
|
687
644
|
messages: LlmsProviders.Message[],
|
|
688
645
|
systemPrompt?: string,
|
|
689
646
|
): Promise<void> {
|
|
690
|
-
const path =
|
|
691
|
-
|
|
692
|
-
|
|
647
|
+
const path = await this.resolveArtifactPath(
|
|
648
|
+
sessionId,
|
|
649
|
+
"messages_path",
|
|
650
|
+
(id) => this.artifacts.sessionMessagesPath(id),
|
|
651
|
+
);
|
|
693
652
|
const payload: {
|
|
694
653
|
version: number;
|
|
695
654
|
updated_at: string;
|
|
696
655
|
systemPrompt?: string;
|
|
697
656
|
messages: LlmsProviders.Message[];
|
|
698
657
|
} = { version: 1, updated_at: nowIso(), messages };
|
|
699
|
-
if (systemPrompt
|
|
700
|
-
payload.systemPrompt = systemPrompt;
|
|
701
|
-
}
|
|
658
|
+
if (systemPrompt) payload.systemPrompt = systemPrompt;
|
|
702
659
|
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
703
660
|
}
|
|
704
661
|
|
|
662
|
+
// ── Subagent status ───────────────────────────────────────────────
|
|
663
|
+
|
|
705
664
|
async applySubagentStatus(
|
|
706
665
|
subSessionId: string,
|
|
707
666
|
event: HookEventPayload,
|
|
@@ -717,17 +676,15 @@ export class UnifiedSessionPersistenceService {
|
|
|
717
676
|
status: SessionStatus,
|
|
718
677
|
): Promise<void> {
|
|
719
678
|
const row = await this.adapter.getSession(subSessionId);
|
|
720
|
-
if (!row || typeof row.status_lock !== "number")
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
const endedAt = status === "running" ? null : ts;
|
|
725
|
-
const exitCode = status === "failed" ? 1 : 0;
|
|
679
|
+
if (!row || typeof row.status_lock !== "number") return;
|
|
680
|
+
|
|
681
|
+
const endedAt = status === "running" ? null : nowIso();
|
|
682
|
+
const exitCode = status === "running" ? null : status === "failed" ? 1 : 0;
|
|
726
683
|
await this.adapter.updateSession({
|
|
727
684
|
sessionId: subSessionId,
|
|
728
685
|
status,
|
|
729
686
|
endedAt,
|
|
730
|
-
exitCode
|
|
687
|
+
exitCode,
|
|
731
688
|
expectedStatusLock: row.status_lock,
|
|
732
689
|
});
|
|
733
690
|
}
|
|
@@ -736,9 +693,7 @@ export class UnifiedSessionPersistenceService {
|
|
|
736
693
|
parentSessionId: string,
|
|
737
694
|
status: Exclude<SessionStatus, "running">,
|
|
738
695
|
): Promise<void> {
|
|
739
|
-
if (!parentSessionId)
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
696
|
+
if (!parentSessionId) return;
|
|
742
697
|
const rows = await this.adapter.listSessions({
|
|
743
698
|
limit: 2000,
|
|
744
699
|
parentSessionId,
|
|
@@ -749,74 +704,38 @@ export class UnifiedSessionPersistenceService {
|
|
|
749
704
|
}
|
|
750
705
|
}
|
|
751
706
|
|
|
752
|
-
|
|
707
|
+
// ── Team tasks ────────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
async onTeamTaskStart(
|
|
753
710
|
rootSessionId: string,
|
|
754
711
|
agentId: string,
|
|
755
712
|
message: string,
|
|
756
|
-
): Promise<
|
|
757
|
-
const root = await this.
|
|
758
|
-
if (!root)
|
|
759
|
-
|
|
760
|
-
}
|
|
713
|
+
): Promise<void> {
|
|
714
|
+
const root = await this.adapter.getSession(rootSessionId);
|
|
715
|
+
if (!root) return;
|
|
716
|
+
|
|
761
717
|
const sessionId = makeTeamTaskSubSessionId(rootSessionId, agentId);
|
|
762
718
|
const startedAt = nowIso();
|
|
763
|
-
const transcriptPath = this.sessionTranscriptPath(sessionId);
|
|
764
|
-
const hookPath = this.sessionHookPath(sessionId);
|
|
765
|
-
const messagesPath = this.sessionMessagesPath(sessionId);
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
workspace_root: root.workspace_root,
|
|
780
|
-
team_name: root.team_name ?? null,
|
|
781
|
-
enable_tools: root.enable_tools,
|
|
782
|
-
enable_spawn: root.enable_spawn,
|
|
783
|
-
enable_teams: root.enable_teams,
|
|
784
|
-
parent_session_id: rootSessionId,
|
|
785
|
-
parent_agent_id: "lead",
|
|
786
|
-
agent_id: agentId,
|
|
787
|
-
conversation_id: null,
|
|
788
|
-
is_subagent: 1,
|
|
789
|
-
prompt: message || `Team task for ${agentId}`,
|
|
790
|
-
metadata_json: stringifyMetadataJson(
|
|
791
|
-
metadataWithResolvedTitle({ prompt: message }),
|
|
792
|
-
),
|
|
793
|
-
transcript_path: transcriptPath,
|
|
794
|
-
hook_path: hookPath,
|
|
795
|
-
messages_path: messagesPath,
|
|
796
|
-
updated_at: startedAt,
|
|
797
|
-
});
|
|
798
|
-
writeFileSync(
|
|
799
|
-
messagesPath,
|
|
800
|
-
`${JSON.stringify({ version: 1, updated_at: startedAt, messages: [] }, null, 2)}\n`,
|
|
801
|
-
"utf8",
|
|
719
|
+
const transcriptPath = this.artifacts.sessionTranscriptPath(sessionId);
|
|
720
|
+
const hookPath = this.artifacts.sessionHookPath(sessionId);
|
|
721
|
+
const messagesPath = this.artifacts.sessionMessagesPath(sessionId);
|
|
722
|
+
|
|
723
|
+
await this.adapter.upsertSession(
|
|
724
|
+
this.buildSubsessionRow(root, {
|
|
725
|
+
sessionId,
|
|
726
|
+
parentSessionId: rootSessionId,
|
|
727
|
+
parentAgentId: "lead",
|
|
728
|
+
agentId,
|
|
729
|
+
prompt: message || `Team task for ${agentId}`,
|
|
730
|
+
startedAt,
|
|
731
|
+
transcriptPath,
|
|
732
|
+
hookPath,
|
|
733
|
+
messagesPath,
|
|
734
|
+
}),
|
|
802
735
|
);
|
|
736
|
+
writeEmptyMessagesFile(messagesPath, startedAt);
|
|
803
737
|
await this.appendSubagentTranscriptLine(sessionId, `[start] ${message}`);
|
|
804
|
-
return sessionId;
|
|
805
|
-
}
|
|
806
738
|
|
|
807
|
-
async onTeamTaskStart(
|
|
808
|
-
rootSessionId: string,
|
|
809
|
-
agentId: string,
|
|
810
|
-
message: string,
|
|
811
|
-
): Promise<void> {
|
|
812
|
-
const sessionId = await this.createTeamTaskSubSession(
|
|
813
|
-
rootSessionId,
|
|
814
|
-
agentId,
|
|
815
|
-
message,
|
|
816
|
-
);
|
|
817
|
-
if (!sessionId) {
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
739
|
const key = this.teamTaskQueueKey(rootSessionId, agentId);
|
|
821
740
|
const queue = this.teamTaskSessionsByAgent.get(key) ?? [];
|
|
822
741
|
queue.push(sessionId);
|
|
@@ -832,21 +751,13 @@ export class UnifiedSessionPersistenceService {
|
|
|
832
751
|
): Promise<void> {
|
|
833
752
|
const key = this.teamTaskQueueKey(rootSessionId, agentId);
|
|
834
753
|
const queue = this.teamTaskSessionsByAgent.get(key);
|
|
835
|
-
if (!queue || queue.length === 0)
|
|
836
|
-
|
|
837
|
-
}
|
|
754
|
+
if (!queue || queue.length === 0) return;
|
|
755
|
+
|
|
838
756
|
const sessionId = queue.shift();
|
|
839
|
-
if (queue.length === 0)
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
}
|
|
844
|
-
if (!sessionId) {
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
if (messages) {
|
|
848
|
-
await this.persistSessionMessages(sessionId, messages);
|
|
849
|
-
}
|
|
757
|
+
if (queue.length === 0) this.teamTaskSessionsByAgent.delete(key);
|
|
758
|
+
if (!sessionId) return;
|
|
759
|
+
|
|
760
|
+
if (messages) await this.persistSessionMessages(sessionId, messages);
|
|
850
761
|
await this.appendSubagentTranscriptLine(
|
|
851
762
|
sessionId,
|
|
852
763
|
summary ?? `[done] ${status}`,
|
|
@@ -854,6 +765,8 @@ export class UnifiedSessionPersistenceService {
|
|
|
854
765
|
await this.applySubagentStatusBySessionId(sessionId, status);
|
|
855
766
|
}
|
|
856
767
|
|
|
768
|
+
// ── SubAgent lifecycle ────────────────────────────────────────────
|
|
769
|
+
|
|
857
770
|
async handleSubAgentStart(
|
|
858
771
|
rootSessionId: string,
|
|
859
772
|
context: SubAgentStartContext,
|
|
@@ -865,9 +778,7 @@ export class UnifiedSessionPersistenceService {
|
|
|
865
778
|
prompt: context.input.task,
|
|
866
779
|
rootSessionId,
|
|
867
780
|
});
|
|
868
|
-
if (!subSessionId)
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
781
|
+
if (!subSessionId) return;
|
|
871
782
|
await this.appendSubagentTranscriptLine(
|
|
872
783
|
subSessionId,
|
|
873
784
|
`[start] ${context.input.task}`,
|
|
@@ -886,9 +797,8 @@ export class UnifiedSessionPersistenceService {
|
|
|
886
797
|
prompt: context.input.task,
|
|
887
798
|
rootSessionId,
|
|
888
799
|
});
|
|
889
|
-
if (!subSessionId)
|
|
890
|
-
|
|
891
|
-
}
|
|
800
|
+
if (!subSessionId) return;
|
|
801
|
+
|
|
892
802
|
if (context.error) {
|
|
893
803
|
await this.appendSubagentTranscriptLine(
|
|
894
804
|
subSessionId,
|
|
@@ -897,21 +807,18 @@ export class UnifiedSessionPersistenceService {
|
|
|
897
807
|
await this.applySubagentStatusBySessionId(subSessionId, "failed");
|
|
898
808
|
return;
|
|
899
809
|
}
|
|
900
|
-
|
|
810
|
+
const reason = context.result?.finishReason ?? "completed";
|
|
811
|
+
await this.appendSubagentTranscriptLine(subSessionId, `[done] ${reason}`);
|
|
812
|
+
await this.applySubagentStatusBySessionId(
|
|
901
813
|
subSessionId,
|
|
902
|
-
|
|
814
|
+
reason === "aborted" ? "cancelled" : "completed",
|
|
903
815
|
);
|
|
904
|
-
if (context.result?.finishReason === "aborted") {
|
|
905
|
-
await this.applySubagentStatusBySessionId(subSessionId, "cancelled");
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
await this.applySubagentStatusBySessionId(subSessionId, "completed");
|
|
909
816
|
}
|
|
910
817
|
|
|
818
|
+
// ── Stale session reconciliation ──────────────────────────────────
|
|
819
|
+
|
|
911
820
|
private isPidAlive(pid: number): boolean {
|
|
912
|
-
if (!Number.isFinite(pid) || pid <= 0)
|
|
913
|
-
return false;
|
|
914
|
-
}
|
|
821
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
915
822
|
try {
|
|
916
823
|
process.kill(Math.floor(pid), 0);
|
|
917
824
|
return true;
|
|
@@ -925,34 +832,125 @@ export class UnifiedSessionPersistenceService {
|
|
|
925
832
|
}
|
|
926
833
|
}
|
|
927
834
|
|
|
835
|
+
private async reconcileDeadRunningSession(
|
|
836
|
+
row: SessionRowShape,
|
|
837
|
+
): Promise<SessionRowShape | undefined> {
|
|
838
|
+
if (row.status !== "running" || this.isPidAlive(row.pid)) return row;
|
|
839
|
+
|
|
840
|
+
const detectedAt = nowIso();
|
|
841
|
+
const reason = UnifiedSessionPersistenceService.STALE_REASON;
|
|
842
|
+
|
|
843
|
+
for (let attempt = 0; attempt < OCC_MAX_RETRIES; attempt++) {
|
|
844
|
+
const latest = await this.adapter.getSession(row.session_id);
|
|
845
|
+
if (!latest) return undefined;
|
|
846
|
+
if (latest.status !== "running") return latest;
|
|
847
|
+
|
|
848
|
+
const nextMetadata = {
|
|
849
|
+
...(parseMetadataJson(latest.metadata_json) ?? {}),
|
|
850
|
+
terminal_marker: reason,
|
|
851
|
+
terminal_marker_at: detectedAt,
|
|
852
|
+
terminal_marker_pid: latest.pid,
|
|
853
|
+
terminal_marker_source: UnifiedSessionPersistenceService.STALE_SOURCE,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const changed = await this.adapter.updateSession({
|
|
857
|
+
sessionId: latest.session_id,
|
|
858
|
+
status: "failed",
|
|
859
|
+
endedAt: detectedAt,
|
|
860
|
+
exitCode: 1,
|
|
861
|
+
metadataJson: stringifyMetadata(nextMetadata),
|
|
862
|
+
expectedStatusLock: latest.status_lock,
|
|
863
|
+
});
|
|
864
|
+
if (!changed.updated) continue;
|
|
865
|
+
|
|
866
|
+
await this.applyStatusToRunningChildSessions(latest.session_id, "failed");
|
|
867
|
+
|
|
868
|
+
const manifest = this.buildManifestFromRow(latest, {
|
|
869
|
+
status: "failed",
|
|
870
|
+
endedAt: detectedAt,
|
|
871
|
+
exitCode: 1,
|
|
872
|
+
metadata: nextMetadata,
|
|
873
|
+
});
|
|
874
|
+
const { path: manifestPath } = this.readManifestFile(latest.session_id);
|
|
875
|
+
this.writeManifestFile(manifestPath, manifest);
|
|
876
|
+
|
|
877
|
+
// Write termination markers to hook + transcript files
|
|
878
|
+
appendFileSync(
|
|
879
|
+
latest.hook_path,
|
|
880
|
+
`${JSON.stringify({
|
|
881
|
+
ts: detectedAt,
|
|
882
|
+
hookName: "session_shutdown",
|
|
883
|
+
reason,
|
|
884
|
+
sessionId: latest.session_id,
|
|
885
|
+
pid: latest.pid,
|
|
886
|
+
source: UnifiedSessionPersistenceService.STALE_SOURCE,
|
|
887
|
+
})}\n`,
|
|
888
|
+
"utf8",
|
|
889
|
+
);
|
|
890
|
+
appendFileSync(
|
|
891
|
+
latest.transcript_path,
|
|
892
|
+
`[shutdown] ${reason} (pid=${latest.pid})\n`,
|
|
893
|
+
"utf8",
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
...latest,
|
|
898
|
+
status: "failed",
|
|
899
|
+
ended_at: detectedAt,
|
|
900
|
+
exit_code: 1,
|
|
901
|
+
metadata_json: stringifyMetadata(nextMetadata),
|
|
902
|
+
status_lock: changed.statusLock,
|
|
903
|
+
updated_at: detectedAt,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
return await this.adapter.getSession(row.session_id);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ── List / reconcile / delete ─────────────────────────────────────
|
|
910
|
+
|
|
928
911
|
async listSessions(limit = 200): Promise<SessionRowShape[]> {
|
|
929
912
|
const requestedLimit = Math.max(1, Math.floor(limit));
|
|
930
913
|
const scanLimit = Math.min(requestedLimit * 5, 2000);
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
)
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
914
|
+
await this.reconcileDeadSessions(scanLimit);
|
|
915
|
+
|
|
916
|
+
const rows = await this.adapter.listSessions({ limit: scanLimit });
|
|
917
|
+
return rows.slice(0, requestedLimit).map((row) => {
|
|
918
|
+
const meta = sanitizeMetadata(parseMetadataJson(row.metadata_json));
|
|
919
|
+
const { manifest } = this.readManifestFile(row.session_id);
|
|
920
|
+
const manifestTitle = normalizeTitle(
|
|
921
|
+
typeof manifest?.metadata?.title === "string"
|
|
922
|
+
? (manifest.metadata.title as string)
|
|
923
|
+
: undefined,
|
|
924
|
+
);
|
|
925
|
+
const resolved = manifestTitle
|
|
926
|
+
? { ...(meta ?? {}), title: manifestTitle }
|
|
927
|
+
: meta;
|
|
928
|
+
return { ...row, metadata_json: stringifyMetadata(resolved) };
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async reconcileDeadSessions(limit = 2000): Promise<number> {
|
|
933
|
+
const rows = await this.adapter.listSessions({
|
|
934
|
+
limit: Math.max(1, Math.floor(limit)),
|
|
935
|
+
status: "running",
|
|
936
|
+
});
|
|
937
|
+
let reconciled = 0;
|
|
938
|
+
for (const row of rows) {
|
|
939
|
+
const updated = await this.reconcileDeadRunningSession(row);
|
|
940
|
+
if (updated && updated.status !== row.status) reconciled++;
|
|
940
941
|
}
|
|
941
|
-
return
|
|
942
|
-
.slice(0, requestedLimit)
|
|
943
|
-
.map((row) => this.applyResolvedTitleToRow(row));
|
|
942
|
+
return reconciled;
|
|
944
943
|
}
|
|
945
944
|
|
|
946
945
|
async deleteSession(sessionId: string): Promise<{ deleted: boolean }> {
|
|
947
946
|
const id = sessionId.trim();
|
|
948
|
-
if (!id)
|
|
949
|
-
|
|
950
|
-
}
|
|
947
|
+
if (!id) throw new Error("session id is required");
|
|
948
|
+
|
|
951
949
|
const row = await this.adapter.getSession(id);
|
|
952
|
-
if (!row) {
|
|
953
|
-
|
|
954
|
-
}
|
|
950
|
+
if (!row) return { deleted: false };
|
|
951
|
+
|
|
955
952
|
await this.adapter.deleteSession(id, false);
|
|
953
|
+
|
|
956
954
|
if (!row.is_subagent) {
|
|
957
955
|
const children = await this.adapter.listSessions({
|
|
958
956
|
limit: 2000,
|
|
@@ -963,14 +961,17 @@ export class UnifiedSessionPersistenceService {
|
|
|
963
961
|
unlinkIfExists(child.transcript_path);
|
|
964
962
|
unlinkIfExists(child.hook_path);
|
|
965
963
|
unlinkIfExists(child.messages_path);
|
|
966
|
-
unlinkIfExists(
|
|
964
|
+
unlinkIfExists(
|
|
965
|
+
this.artifacts.sessionManifestPath(child.session_id, false),
|
|
966
|
+
);
|
|
967
967
|
this.artifacts.removeSessionDirIfEmpty(child.session_id);
|
|
968
968
|
}
|
|
969
969
|
}
|
|
970
|
+
|
|
970
971
|
unlinkIfExists(row.transcript_path);
|
|
971
972
|
unlinkIfExists(row.hook_path);
|
|
972
973
|
unlinkIfExists(row.messages_path);
|
|
973
|
-
unlinkIfExists(this.sessionManifestPath(id, false));
|
|
974
|
+
unlinkIfExists(this.artifacts.sessionManifestPath(id, false));
|
|
974
975
|
this.artifacts.removeSessionDirIfEmpty(id);
|
|
975
976
|
return { deleted: true };
|
|
976
977
|
}
|