@jmoyers/harness 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -155
- package/package.json +3 -1
- package/packages/harness-ai/src/anthropic-client.ts +99 -0
- package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
- package/packages/harness-ai/src/anthropic-provider.ts +82 -0
- package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
- package/packages/harness-ai/src/index.ts +36 -0
- package/packages/harness-ai/src/json-parse.ts +66 -0
- package/packages/harness-ai/src/sse.ts +80 -0
- package/packages/harness-ai/src/stream-object.ts +96 -0
- package/packages/harness-ai/src/stream-text.ts +1340 -0
- package/packages/harness-ai/src/types.ts +330 -0
- package/packages/harness-ai/src/ui-stream.ts +217 -0
- package/scripts/codex-live-mux-runtime.ts +265 -14
- package/scripts/control-plane-daemon.ts +33 -5
- package/scripts/harness.ts +579 -134
- package/src/cli/default-gateway-pointer.ts +193 -0
- package/src/cli/gateway-record.ts +16 -1
- package/src/config/config-core.ts +13 -2
- package/src/config/harness-paths.ts +4 -7
- package/src/config/harness-runtime-migration.ts +142 -19
- package/src/config/secrets-core.ts +92 -4
- package/src/control-plane/prompt/thread-title-namer.ts +316 -0
- package/src/control-plane/stream-command-parser.ts +12 -0
- package/src/control-plane/stream-protocol.ts +6 -0
- package/src/control-plane/stream-server-background.ts +18 -2
- package/src/control-plane/stream-server-command.ts +14 -0
- package/src/control-plane/stream-server.ts +460 -28
- package/src/domain/conversations.ts +11 -7
- package/src/domain/workspace.ts +9 -0
- package/src/mux/input-shortcuts.ts +38 -1
- package/src/mux/live-mux/git-parsing.ts +40 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
- package/src/mux/live-mux/modal-input-reducers.ts +34 -1
- package/src/mux/live-mux/modal-overlays.ts +45 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
- package/src/mux/render-frame.ts +1 -1
- package/src/mux/task-screen-keybindings.ts +29 -1
- package/src/services/control-plane.ts +22 -0
- package/src/services/runtime-control-actions.ts +69 -0
- package/src/services/runtime-conversation-activation.ts +25 -0
- package/src/services/runtime-conversation-starter.ts +31 -7
- package/src/services/runtime-input-router.ts +6 -0
- package/src/services/runtime-modal-input.ts +18 -0
- package/src/services/runtime-navigation-input.ts +4 -0
- package/src/services/runtime-rail-input.ts +5 -0
- package/src/services/runtime-repository-actions.ts +2 -0
- package/src/services/runtime-workspace-actions.ts +5 -0
- package/src/store/control-plane-store.ts +36 -0
- package/src/store/event-store.ts +36 -0
- package/src/ui/global-shortcut-input.ts +2 -0
- package/src/ui/input.ts +31 -0
- package/src/ui/modals/manager.ts +26 -0
|
@@ -85,6 +85,14 @@ import {
|
|
|
85
85
|
import { closeOwnedStateStore as closeOwnedStreamServerStateStore } from './stream-server-state-store.ts';
|
|
86
86
|
import { SessionStatusEngine } from './status/session-status-engine.ts';
|
|
87
87
|
import { SessionPromptEngine } from './prompt/session-prompt-engine.ts';
|
|
88
|
+
import {
|
|
89
|
+
appendThreadTitlePromptHistory,
|
|
90
|
+
createAnthropicThreadTitleNamer,
|
|
91
|
+
fallbackThreadTitleFromPromptHistory,
|
|
92
|
+
normalizeThreadTitleCandidate,
|
|
93
|
+
readThreadTitlePromptHistory,
|
|
94
|
+
type ThreadTitleNamer,
|
|
95
|
+
} from './prompt/thread-title-namer.ts';
|
|
88
96
|
import {
|
|
89
97
|
eventIncludesRepositoryId as filterEventIncludesRepositoryId,
|
|
90
98
|
eventIncludesTaskId as filterEventIncludesTaskId,
|
|
@@ -222,6 +230,14 @@ interface GitHubIntegrationConfig {
|
|
|
222
230
|
readonly viewerLogin: string | null;
|
|
223
231
|
}
|
|
224
232
|
|
|
233
|
+
interface ThreadTitleConfig {
|
|
234
|
+
readonly enabled: boolean;
|
|
235
|
+
readonly apiKey: string | null;
|
|
236
|
+
readonly modelId: string | null;
|
|
237
|
+
readonly baseUrl: string | null;
|
|
238
|
+
readonly fetch: typeof fetch | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
225
241
|
interface GitHubRemotePullRequest {
|
|
226
242
|
readonly number: number;
|
|
227
243
|
readonly title: string;
|
|
@@ -285,6 +301,8 @@ interface StartControlPlaneStreamServerOptions {
|
|
|
285
301
|
githubFetch?: typeof fetch;
|
|
286
302
|
readGitDirectorySnapshot?: GitDirectorySnapshotReader;
|
|
287
303
|
lifecycleHooks?: HarnessLifecycleHooksConfig;
|
|
304
|
+
threadTitle?: Partial<ThreadTitleConfig>;
|
|
305
|
+
threadTitleNamer?: ThreadTitleNamer;
|
|
288
306
|
}
|
|
289
307
|
|
|
290
308
|
interface ConnectionState {
|
|
@@ -385,6 +403,17 @@ interface StreamJournalEntry {
|
|
|
385
403
|
event: StreamObservedEvent;
|
|
386
404
|
}
|
|
387
405
|
|
|
406
|
+
interface ThreadTitleRefreshState {
|
|
407
|
+
inFlight: boolean;
|
|
408
|
+
pending: boolean;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
interface ThreadTitleRefreshResult {
|
|
412
|
+
readonly status: 'updated' | 'unchanged' | 'skipped';
|
|
413
|
+
readonly conversation: ControlPlaneConversationRecord | null;
|
|
414
|
+
readonly reason: string | null;
|
|
415
|
+
}
|
|
416
|
+
|
|
388
417
|
interface DirectoryGitStatusCacheEntry {
|
|
389
418
|
readonly summary: GitDirectorySnapshot['summary'];
|
|
390
419
|
readonly repositorySnapshot: GitDirectorySnapshot['repository'];
|
|
@@ -437,12 +466,17 @@ const DEFAULT_AGENT_INSTALL_COMMANDS: Readonly<Record<AgentToolType, string | nu
|
|
|
437
466
|
const DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH = fileURLToPath(
|
|
438
467
|
new URL('../../scripts/cursor-hook-relay.ts', import.meta.url),
|
|
439
468
|
);
|
|
469
|
+
const THREAD_TITLE_AGENT_TYPES = new Set(['codex', 'claude', 'cursor']);
|
|
440
470
|
const LIFECYCLE_TELEMETRY_EVENT_NAMES = new Set([
|
|
441
471
|
'codex.user_prompt',
|
|
442
472
|
'codex.turn.e2e_duration_ms',
|
|
443
473
|
'codex.conversation_starts',
|
|
444
474
|
]);
|
|
445
475
|
|
|
476
|
+
function isThreadTitleAgentType(agentType: string): boolean {
|
|
477
|
+
return THREAD_TITLE_AGENT_TYPES.has(agentType.trim().toLowerCase());
|
|
478
|
+
}
|
|
479
|
+
|
|
446
480
|
function shellEscape(value: string): string {
|
|
447
481
|
if (value.length === 0) {
|
|
448
482
|
return "''";
|
|
@@ -738,6 +772,33 @@ function normalizeGitHubIntegrationConfig(
|
|
|
738
772
|
};
|
|
739
773
|
}
|
|
740
774
|
|
|
775
|
+
function normalizeThreadTitleConfig(
|
|
776
|
+
input: Partial<ThreadTitleConfig> | undefined,
|
|
777
|
+
): ThreadTitleConfig {
|
|
778
|
+
const envApiKeyRaw = process.env.ANTHROPIC_API_KEY;
|
|
779
|
+
const envApiKey =
|
|
780
|
+
typeof envApiKeyRaw === 'string' && envApiKeyRaw.trim().length > 0 ? envApiKeyRaw.trim() : null;
|
|
781
|
+
const apiKeyRaw = input?.apiKey ?? envApiKey;
|
|
782
|
+
const apiKey =
|
|
783
|
+
typeof apiKeyRaw === 'string' && apiKeyRaw.trim().length > 0 ? apiKeyRaw.trim() : null;
|
|
784
|
+
const envModelRaw = process.env.HARNESS_THREAD_TITLE_MODEL;
|
|
785
|
+
const modelFromEnv =
|
|
786
|
+
typeof envModelRaw === 'string' && envModelRaw.trim().length > 0 ? envModelRaw.trim() : null;
|
|
787
|
+
const modelIdRaw = input?.modelId ?? modelFromEnv;
|
|
788
|
+
const modelId =
|
|
789
|
+
typeof modelIdRaw === 'string' && modelIdRaw.trim().length > 0 ? modelIdRaw.trim() : null;
|
|
790
|
+
const baseUrlRaw = input?.baseUrl;
|
|
791
|
+
const baseUrl =
|
|
792
|
+
typeof baseUrlRaw === 'string' && baseUrlRaw.trim().length > 0 ? baseUrlRaw.trim() : null;
|
|
793
|
+
return {
|
|
794
|
+
enabled: input?.enabled ?? apiKey !== null,
|
|
795
|
+
apiKey,
|
|
796
|
+
modelId,
|
|
797
|
+
baseUrl,
|
|
798
|
+
fetch: input?.fetch ?? null,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
741
802
|
function parseGitHubOwnerRepoFromRemote(remoteUrl: string): { owner: string; repo: string } | null {
|
|
742
803
|
const trimmed = remoteUrl.trim();
|
|
743
804
|
if (trimmed.length === 0) {
|
|
@@ -1059,7 +1120,10 @@ export class ControlPlaneStreamServer {
|
|
|
1059
1120
|
private readonly readGitDirectorySnapshot: GitDirectorySnapshotReader;
|
|
1060
1121
|
private readonly statusEngine = new SessionStatusEngine();
|
|
1061
1122
|
private readonly promptEngine = new SessionPromptEngine();
|
|
1123
|
+
private readonly threadTitleNamer: ThreadTitleNamer | null;
|
|
1062
1124
|
private readonly promptEventDedupeByKey = new Map<string, number>();
|
|
1125
|
+
private readonly threadTitleRevisionBySessionId = new Map<string, number>();
|
|
1126
|
+
private readonly threadTitleRefreshBySessionId = new Map<string, ThreadTitleRefreshState>();
|
|
1063
1127
|
private readonly server: Server;
|
|
1064
1128
|
private readonly telemetryServer: HttpServer | null;
|
|
1065
1129
|
private telemetryAddress: AddressInfo | null = null;
|
|
@@ -1077,6 +1141,7 @@ export class ControlPlaneStreamServer {
|
|
|
1077
1141
|
private githubTokenResolutionError: string | null = null;
|
|
1078
1142
|
private gitStatusPollInFlight = false;
|
|
1079
1143
|
private githubPollInFlight = false;
|
|
1144
|
+
private githubPollPromise: Promise<void> | null = null;
|
|
1080
1145
|
private readonly gitStatusRefreshInFlightDirectoryIds = new Set<string>();
|
|
1081
1146
|
private readonly gitStatusByDirectoryId = new Map<string, DirectoryGitStatusCacheEntry>();
|
|
1082
1147
|
private readonly gitStatusDirectoriesById = new Map<string, ControlPlaneDirectoryRecord>();
|
|
@@ -1088,6 +1153,7 @@ export class ControlPlaneStreamServer {
|
|
|
1088
1153
|
private streamCursor = 0;
|
|
1089
1154
|
private listening = false;
|
|
1090
1155
|
private stateStoreClosed = false;
|
|
1156
|
+
private closing = false;
|
|
1091
1157
|
|
|
1092
1158
|
constructor(options: StartControlPlaneStreamServerOptions = {}) {
|
|
1093
1159
|
this.host = options.host ?? '127.0.0.1';
|
|
@@ -1133,6 +1199,19 @@ export class ControlPlaneStreamServer {
|
|
|
1133
1199
|
await readGitDirectorySnapshot(cwd, undefined, {
|
|
1134
1200
|
includeCommitCount: false,
|
|
1135
1201
|
}));
|
|
1202
|
+
const threadTitleConfig = normalizeThreadTitleConfig(options.threadTitle);
|
|
1203
|
+
if (options.threadTitleNamer !== undefined) {
|
|
1204
|
+
this.threadTitleNamer = options.threadTitleNamer;
|
|
1205
|
+
} else if (threadTitleConfig.enabled && threadTitleConfig.apiKey !== null) {
|
|
1206
|
+
this.threadTitleNamer = createAnthropicThreadTitleNamer({
|
|
1207
|
+
apiKey: threadTitleConfig.apiKey,
|
|
1208
|
+
...(threadTitleConfig.modelId === null ? {} : { modelId: threadTitleConfig.modelId }),
|
|
1209
|
+
...(threadTitleConfig.baseUrl === null ? {} : { baseUrl: threadTitleConfig.baseUrl }),
|
|
1210
|
+
...(threadTitleConfig.fetch === null ? {} : { fetch: threadTitleConfig.fetch }),
|
|
1211
|
+
});
|
|
1212
|
+
} else {
|
|
1213
|
+
this.threadTitleNamer = null;
|
|
1214
|
+
}
|
|
1136
1215
|
this.lifecycleHooks = new LifecycleHooksRuntime(
|
|
1137
1216
|
options.lifecycleHooks ?? {
|
|
1138
1217
|
enabled: false,
|
|
@@ -1211,9 +1290,11 @@ export class ControlPlaneStreamServer {
|
|
|
1211
1290
|
}
|
|
1212
1291
|
|
|
1213
1292
|
async close(): Promise<void> {
|
|
1293
|
+
this.closing = true;
|
|
1214
1294
|
this.stopHistoryPolling();
|
|
1215
1295
|
this.stopGitStatusPolling();
|
|
1216
1296
|
this.stopGitHubPolling();
|
|
1297
|
+
await this.waitForGitHubPollingToSettle();
|
|
1217
1298
|
|
|
1218
1299
|
for (const sessionId of [...this.sessions.keys()]) {
|
|
1219
1300
|
this.destroySession(sessionId, true);
|
|
@@ -1300,7 +1381,18 @@ export class ControlPlaneStreamServer {
|
|
|
1300
1381
|
}
|
|
1301
1382
|
|
|
1302
1383
|
private pollHistoryTimerTick(): void {
|
|
1303
|
-
void this.pollHistoryFile()
|
|
1384
|
+
void this.pollHistoryFile().catch((error: unknown) => {
|
|
1385
|
+
if (this.markStateStoreClosedIfDetected(error)) {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1392
|
+
recordPerfEvent('control-plane.history.poll.failed', {
|
|
1393
|
+
error: message,
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1304
1396
|
}
|
|
1305
1397
|
|
|
1306
1398
|
private stopHistoryPolling(): void {
|
|
@@ -1318,9 +1410,9 @@ export class ControlPlaneStreamServer {
|
|
|
1318
1410
|
return;
|
|
1319
1411
|
}
|
|
1320
1412
|
this.reloadGitStatusDirectoriesFromStore();
|
|
1321
|
-
|
|
1413
|
+
this.triggerGitStatusPoll();
|
|
1322
1414
|
this.gitStatusPollTimer = setInterval(() => {
|
|
1323
|
-
|
|
1415
|
+
this.triggerGitStatusPoll();
|
|
1324
1416
|
}, this.gitStatusMonitor.pollMs);
|
|
1325
1417
|
this.gitStatusPollTimer.unref();
|
|
1326
1418
|
}
|
|
@@ -1396,9 +1488,9 @@ export class ControlPlaneStreamServer {
|
|
|
1396
1488
|
if (!this.github.enabled || this.githubPollTimer !== null) {
|
|
1397
1489
|
return;
|
|
1398
1490
|
}
|
|
1399
|
-
|
|
1491
|
+
this.triggerGitHubPoll();
|
|
1400
1492
|
this.githubPollTimer = setInterval(() => {
|
|
1401
|
-
|
|
1493
|
+
this.triggerGitHubPoll();
|
|
1402
1494
|
}, this.github.pollMs);
|
|
1403
1495
|
this.githubPollTimer.unref();
|
|
1404
1496
|
}
|
|
@@ -1411,6 +1503,85 @@ export class ControlPlaneStreamServer {
|
|
|
1411
1503
|
this.githubPollTimer = null;
|
|
1412
1504
|
}
|
|
1413
1505
|
|
|
1506
|
+
private triggerGitHubPoll(): void {
|
|
1507
|
+
void this.pollGitHub().catch((error: unknown) => {
|
|
1508
|
+
if (this.markStateStoreClosedIfDetected(error)) {
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (this.shouldIgnoreGitHubPollError(error)) {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1515
|
+
recordPerfEvent('control-plane.github.poll.failed', {
|
|
1516
|
+
error: message,
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
private triggerGitStatusPoll(): void {
|
|
1522
|
+
void this.pollGitStatus().catch((error: unknown) => {
|
|
1523
|
+
if (this.markStateStoreClosedIfDetected(error)) {
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1530
|
+
recordPerfEvent('control-plane.git-status.poll.failed', {
|
|
1531
|
+
error: message,
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
private isStateStoreClosedError(error: unknown): boolean {
|
|
1537
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1538
|
+
const normalized = message.trim().toLowerCase();
|
|
1539
|
+
return (
|
|
1540
|
+
normalized.includes('database has closed') ||
|
|
1541
|
+
normalized.includes('database is closed') ||
|
|
1542
|
+
normalized.includes('cannot use a closed database')
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
private markStateStoreClosedIfDetected(error: unknown): boolean {
|
|
1547
|
+
if (!this.isStateStoreClosedError(error)) {
|
|
1548
|
+
return false;
|
|
1549
|
+
}
|
|
1550
|
+
this.stateStoreClosed = true;
|
|
1551
|
+
this.stopGitHubPolling();
|
|
1552
|
+
this.stopGitStatusPolling();
|
|
1553
|
+
this.stopHistoryPolling();
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
private shouldSkipStateStoreWork(): boolean {
|
|
1558
|
+
return this.closing || this.stateStoreClosed;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
private shouldIgnoreGitHubPollError(error: unknown): boolean {
|
|
1562
|
+
return this.shouldSkipStateStoreWork() || this.isStateStoreClosedError(error);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
private async waitForGitHubPollingToSettle(): Promise<void> {
|
|
1566
|
+
const pollPromise = this.githubPollPromise;
|
|
1567
|
+
if (pollPromise === null) {
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
await pollPromise;
|
|
1572
|
+
} catch (error: unknown) {
|
|
1573
|
+
if (this.markStateStoreClosedIfDetected(error)) {
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (!this.shouldIgnoreGitHubPollError(error)) {
|
|
1577
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1578
|
+
recordPerfEvent('control-plane.github.poll.failed-on-close', {
|
|
1579
|
+
error: message,
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1414
1585
|
private reloadGitStatusDirectoriesFromStore(): void {
|
|
1415
1586
|
const directories = this.stateStore.listDirectories({
|
|
1416
1587
|
includeArchived: false,
|
|
@@ -2081,9 +2252,15 @@ export class ControlPlaneStreamServer {
|
|
|
2081
2252
|
}
|
|
2082
2253
|
|
|
2083
2254
|
private async pollHistoryFile(): Promise<void> {
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2255
|
+
try {
|
|
2256
|
+
await pollStreamServerHistoryFile(
|
|
2257
|
+
this as unknown as Parameters<typeof pollStreamServerHistoryFile>[0],
|
|
2258
|
+
);
|
|
2259
|
+
} catch (error: unknown) {
|
|
2260
|
+
if (!this.markStateStoreClosedIfDetected(error)) {
|
|
2261
|
+
throw error;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2087
2264
|
}
|
|
2088
2265
|
|
|
2089
2266
|
private async pollHistoryFileUnsafe(): Promise<boolean> {
|
|
@@ -2093,9 +2270,15 @@ export class ControlPlaneStreamServer {
|
|
|
2093
2270
|
}
|
|
2094
2271
|
|
|
2095
2272
|
private async pollGitStatus(): Promise<void> {
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2273
|
+
try {
|
|
2274
|
+
await pollStreamServerGitStatus(
|
|
2275
|
+
this as unknown as Parameters<typeof pollStreamServerGitStatus>[0],
|
|
2276
|
+
);
|
|
2277
|
+
} catch (error: unknown) {
|
|
2278
|
+
if (!this.markStateStoreClosedIfDetected(error)) {
|
|
2279
|
+
throw error;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2099
2282
|
}
|
|
2100
2283
|
|
|
2101
2284
|
private async refreshGitStatusForDirectory(
|
|
@@ -2112,11 +2295,11 @@ export class ControlPlaneStreamServer {
|
|
|
2112
2295
|
}
|
|
2113
2296
|
|
|
2114
2297
|
private async pollGitHub(): Promise<void> {
|
|
2115
|
-
if (!this.github.enabled || this.githubPollInFlight) {
|
|
2298
|
+
if (!this.github.enabled || this.githubPollInFlight || this.shouldSkipStateStoreWork()) {
|
|
2116
2299
|
return;
|
|
2117
2300
|
}
|
|
2118
2301
|
this.githubPollInFlight = true;
|
|
2119
|
-
|
|
2302
|
+
const pollPromise = (async () => {
|
|
2120
2303
|
const directories = this.stateStore.listDirectories({
|
|
2121
2304
|
includeArchived: false,
|
|
2122
2305
|
limit: 1000,
|
|
@@ -2132,6 +2315,9 @@ export class ControlPlaneStreamServer {
|
|
|
2132
2315
|
}
|
|
2133
2316
|
>();
|
|
2134
2317
|
for (const directory of directories) {
|
|
2318
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2135
2321
|
const gitStatus = this.gitStatusByDirectoryId.get(directory.directoryId);
|
|
2136
2322
|
const repositoryId = gitStatus?.repositoryId ?? null;
|
|
2137
2323
|
if (repositoryId === null) {
|
|
@@ -2166,21 +2352,35 @@ export class ControlPlaneStreamServer {
|
|
|
2166
2352
|
branchName,
|
|
2167
2353
|
});
|
|
2168
2354
|
}
|
|
2169
|
-
if (targetsByKey.size === 0) {
|
|
2355
|
+
if (targetsByKey.size === 0 || this.shouldSkipStateStoreWork()) {
|
|
2170
2356
|
return;
|
|
2171
2357
|
}
|
|
2172
2358
|
const token = this.github.token ?? (await this.resolveGitHubTokenIfNeeded());
|
|
2173
|
-
if (token === null) {
|
|
2359
|
+
if (token === null || this.shouldSkipStateStoreWork()) {
|
|
2174
2360
|
return;
|
|
2175
2361
|
}
|
|
2176
2362
|
await runWithConcurrencyLimit(
|
|
2177
2363
|
[...targetsByKey.values()],
|
|
2178
2364
|
this.github.maxConcurrency,
|
|
2179
2365
|
async (target) => {
|
|
2366
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2180
2369
|
await this.syncGitHubBranch(target);
|
|
2181
2370
|
},
|
|
2182
2371
|
);
|
|
2372
|
+
})();
|
|
2373
|
+
this.githubPollPromise = pollPromise;
|
|
2374
|
+
try {
|
|
2375
|
+
await pollPromise;
|
|
2376
|
+
} catch (error: unknown) {
|
|
2377
|
+
if (!this.markStateStoreClosedIfDetected(error)) {
|
|
2378
|
+
throw error;
|
|
2379
|
+
}
|
|
2183
2380
|
} finally {
|
|
2381
|
+
if (this.githubPollPromise === pollPromise) {
|
|
2382
|
+
this.githubPollPromise = null;
|
|
2383
|
+
}
|
|
2184
2384
|
this.githubPollInFlight = false;
|
|
2185
2385
|
}
|
|
2186
2386
|
}
|
|
@@ -2192,6 +2392,9 @@ export class ControlPlaneStreamServer {
|
|
|
2192
2392
|
repo: string;
|
|
2193
2393
|
branchName: string;
|
|
2194
2394
|
}): Promise<void> {
|
|
2395
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2195
2398
|
const now = new Date().toISOString();
|
|
2196
2399
|
const syncStateId = `github-sync:${input.repository.repositoryId}:${input.directory.directoryId}:${input.branchName}`;
|
|
2197
2400
|
try {
|
|
@@ -2200,6 +2403,9 @@ export class ControlPlaneStreamServer {
|
|
|
2200
2403
|
repo: input.repo,
|
|
2201
2404
|
headBranch: input.branchName,
|
|
2202
2405
|
});
|
|
2406
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2203
2409
|
if (remotePr === null) {
|
|
2204
2410
|
const staleOpen = this.stateStore.listGitHubPullRequests({
|
|
2205
2411
|
repositoryId: input.repository.repositoryId,
|
|
@@ -2286,6 +2492,9 @@ export class ControlPlaneStreamServer {
|
|
|
2286
2492
|
repo: input.repo,
|
|
2287
2493
|
headSha: storedPr.headSha,
|
|
2288
2494
|
});
|
|
2495
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2289
2498
|
const rollup = summarizeGitHubCiRollup(jobs);
|
|
2290
2499
|
const updatedPr =
|
|
2291
2500
|
this.stateStore.updateGitHubPullRequestCiRollup(
|
|
@@ -2345,20 +2554,35 @@ export class ControlPlaneStreamServer {
|
|
|
2345
2554
|
lastErrorAt: null,
|
|
2346
2555
|
});
|
|
2347
2556
|
} catch (error: unknown) {
|
|
2557
|
+
if (this.markStateStoreClosedIfDetected(error)) {
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
if (this.shouldIgnoreGitHubPollError(error)) {
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2348
2563
|
const message = error instanceof Error ? error.message : String(error);
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2564
|
+
try {
|
|
2565
|
+
this.stateStore.upsertGitHubSyncState({
|
|
2566
|
+
stateId: syncStateId,
|
|
2567
|
+
tenantId: input.directory.tenantId,
|
|
2568
|
+
userId: input.directory.userId,
|
|
2569
|
+
workspaceId: input.directory.workspaceId,
|
|
2570
|
+
repositoryId: input.repository.repositoryId,
|
|
2571
|
+
directoryId: input.directory.directoryId,
|
|
2572
|
+
branchName: input.branchName,
|
|
2573
|
+
lastSyncAt: now,
|
|
2574
|
+
lastSuccessAt: null,
|
|
2575
|
+
lastError: message,
|
|
2576
|
+
lastErrorAt: now,
|
|
2577
|
+
});
|
|
2578
|
+
} catch (syncStateError: unknown) {
|
|
2579
|
+
if (this.markStateStoreClosedIfDetected(syncStateError)) {
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
if (!this.shouldIgnoreGitHubPollError(syncStateError)) {
|
|
2583
|
+
throw syncStateError;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2362
2586
|
}
|
|
2363
2587
|
}
|
|
2364
2588
|
|
|
@@ -2648,6 +2872,27 @@ export class ControlPlaneStreamServer {
|
|
|
2648
2872
|
);
|
|
2649
2873
|
}
|
|
2650
2874
|
|
|
2875
|
+
private async refreshConversationTitle(conversationId: string): Promise<{
|
|
2876
|
+
conversation: ControlPlaneConversationRecord;
|
|
2877
|
+
status: 'updated' | 'unchanged' | 'skipped';
|
|
2878
|
+
reason: string | null;
|
|
2879
|
+
}> {
|
|
2880
|
+
const existing = this.stateStore.getConversation(conversationId);
|
|
2881
|
+
if (existing === null) {
|
|
2882
|
+
throw new Error(`conversation not found: ${conversationId}`);
|
|
2883
|
+
}
|
|
2884
|
+
const refreshed = await this.refreshThreadTitle(conversationId);
|
|
2885
|
+
const conversation = this.stateStore.getConversation(conversationId);
|
|
2886
|
+
if (conversation === null) {
|
|
2887
|
+
throw new Error(`conversation not found: ${conversationId}`);
|
|
2888
|
+
}
|
|
2889
|
+
return {
|
|
2890
|
+
conversation,
|
|
2891
|
+
status: refreshed.status,
|
|
2892
|
+
reason: refreshed.reason,
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2651
2896
|
private handleInput(connectionId: string, sessionId: string, dataBase64: string): void {
|
|
2652
2897
|
handleRuntimeInput(
|
|
2653
2898
|
this as unknown as Parameters<typeof handleRuntimeInput>[0],
|
|
@@ -2749,6 +2994,7 @@ export class ControlPlaneStreamServer {
|
|
|
2749
2994
|
if (!this.shouldPublishPromptEvent(sessionId, prompt)) {
|
|
2750
2995
|
return;
|
|
2751
2996
|
}
|
|
2997
|
+
this.recordThreadPrompt(sessionId, prompt);
|
|
2752
2998
|
this.publishObservedEvent(scope, {
|
|
2753
2999
|
type: 'session-prompt-event',
|
|
2754
3000
|
sessionId,
|
|
@@ -2774,6 +3020,190 @@ export class ControlPlaneStreamServer {
|
|
|
2774
3020
|
this.publishSessionPromptObservedEventForScope(state.id, this.sessionScope(state), prompt);
|
|
2775
3021
|
}
|
|
2776
3022
|
|
|
3023
|
+
private recordThreadPrompt(sessionId: string, prompt: StreamSessionPromptRecord): void {
|
|
3024
|
+
const conversation = this.stateStore.getConversation(sessionId);
|
|
3025
|
+
if (conversation === null) {
|
|
3026
|
+
this.threadTitleRevisionBySessionId.delete(sessionId);
|
|
3027
|
+
this.threadTitleRefreshBySessionId.delete(sessionId);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
if (!isThreadTitleAgentType(conversation.agentType)) {
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
const appended = appendThreadTitlePromptHistory(conversation.adapterState, prompt);
|
|
3034
|
+
if (!appended.added) {
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
this.stateStore.updateConversationAdapterState(sessionId, appended.nextAdapterState);
|
|
3038
|
+
const liveState = this.sessions.get(sessionId);
|
|
3039
|
+
if (liveState !== undefined) {
|
|
3040
|
+
liveState.adapterState = appended.nextAdapterState;
|
|
3041
|
+
}
|
|
3042
|
+
const nextRevision = (this.threadTitleRevisionBySessionId.get(sessionId) ?? 0) + 1;
|
|
3043
|
+
this.threadTitleRevisionBySessionId.set(sessionId, nextRevision);
|
|
3044
|
+
if (this.threadTitleNamer !== null) {
|
|
3045
|
+
this.scheduleThreadTitleRefresh(sessionId);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
private scheduleThreadTitleRefresh(sessionId: string): void {
|
|
3050
|
+
const state = this.threadTitleRefreshBySessionId.get(sessionId) ?? {
|
|
3051
|
+
inFlight: false,
|
|
3052
|
+
pending: false,
|
|
3053
|
+
};
|
|
3054
|
+
this.threadTitleRefreshBySessionId.set(sessionId, state);
|
|
3055
|
+
if (state.inFlight) {
|
|
3056
|
+
state.pending = true;
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
state.inFlight = true;
|
|
3060
|
+
state.pending = false;
|
|
3061
|
+
void this.runThreadTitleRefreshLoop(sessionId, state);
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
private async runThreadTitleRefreshLoop(
|
|
3065
|
+
sessionId: string,
|
|
3066
|
+
refreshState: ThreadTitleRefreshState,
|
|
3067
|
+
): Promise<void> {
|
|
3068
|
+
let shouldReschedule = false;
|
|
3069
|
+
try {
|
|
3070
|
+
while (true) {
|
|
3071
|
+
refreshState.pending = false;
|
|
3072
|
+
const revision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
|
|
3073
|
+
if (revision <= 0) {
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
await this.refreshThreadTitleForRevision(sessionId, revision);
|
|
3077
|
+
const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
|
|
3078
|
+
if (!refreshState.pending && latestRevision === revision) {
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
} finally {
|
|
3083
|
+
refreshState.inFlight = false;
|
|
3084
|
+
shouldReschedule = refreshState.pending;
|
|
3085
|
+
if (!shouldReschedule) {
|
|
3086
|
+
this.threadTitleRefreshBySessionId.delete(sessionId);
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
if (shouldReschedule) {
|
|
3090
|
+
this.scheduleThreadTitleRefresh(sessionId);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
private async refreshThreadTitle(
|
|
3095
|
+
sessionId: string,
|
|
3096
|
+
expectedRevision?: number,
|
|
3097
|
+
): Promise<ThreadTitleRefreshResult> {
|
|
3098
|
+
if (this.threadTitleNamer === null) {
|
|
3099
|
+
return {
|
|
3100
|
+
status: 'skipped',
|
|
3101
|
+
conversation: this.stateStore.getConversation(sessionId),
|
|
3102
|
+
reason: 'thread-title-namer-disabled',
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
const conversation = this.stateStore.getConversation(sessionId);
|
|
3106
|
+
if (conversation === null) {
|
|
3107
|
+
if (expectedRevision !== undefined) {
|
|
3108
|
+
this.threadTitleRevisionBySessionId.delete(sessionId);
|
|
3109
|
+
}
|
|
3110
|
+
return {
|
|
3111
|
+
status: 'skipped',
|
|
3112
|
+
conversation: null,
|
|
3113
|
+
reason: 'conversation-not-found',
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
if (!isThreadTitleAgentType(conversation.agentType)) {
|
|
3117
|
+
return {
|
|
3118
|
+
status: 'skipped',
|
|
3119
|
+
conversation,
|
|
3120
|
+
reason: 'non-agent-thread',
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
const promptHistory = readThreadTitlePromptHistory(conversation.adapterState);
|
|
3124
|
+
if (promptHistory.length === 0) {
|
|
3125
|
+
return {
|
|
3126
|
+
status: 'skipped',
|
|
3127
|
+
conversation,
|
|
3128
|
+
reason: 'prompt-history-empty',
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
let suggestedTitle: string | null = null;
|
|
3133
|
+
try {
|
|
3134
|
+
suggestedTitle = await this.threadTitleNamer.suggest({
|
|
3135
|
+
conversationId: conversation.conversationId,
|
|
3136
|
+
agentType: conversation.agentType,
|
|
3137
|
+
currentTitle: conversation.title,
|
|
3138
|
+
promptHistory,
|
|
3139
|
+
});
|
|
3140
|
+
} catch (error: unknown) {
|
|
3141
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3142
|
+
recordPerfEvent('control-plane.thread-title.error', {
|
|
3143
|
+
sessionId,
|
|
3144
|
+
error: message,
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
if (expectedRevision !== undefined) {
|
|
3149
|
+
const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
|
|
3150
|
+
if (latestRevision !== expectedRevision) {
|
|
3151
|
+
return {
|
|
3152
|
+
status: 'skipped',
|
|
3153
|
+
conversation,
|
|
3154
|
+
reason: 'stale-revision',
|
|
3155
|
+
};
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
const nextTitle =
|
|
3160
|
+
(suggestedTitle === null ? null : normalizeThreadTitleCandidate(suggestedTitle)) ??
|
|
3161
|
+
fallbackThreadTitleFromPromptHistory(promptHistory);
|
|
3162
|
+
if (conversation.title === nextTitle) {
|
|
3163
|
+
return {
|
|
3164
|
+
status: 'unchanged',
|
|
3165
|
+
conversation,
|
|
3166
|
+
reason: null,
|
|
3167
|
+
};
|
|
3168
|
+
}
|
|
3169
|
+
const updated = this.stateStore.updateConversationTitle(sessionId, nextTitle);
|
|
3170
|
+
if (updated === null) {
|
|
3171
|
+
return {
|
|
3172
|
+
status: 'skipped',
|
|
3173
|
+
conversation: null,
|
|
3174
|
+
reason: 'conversation-not-found',
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
this.publishConversationUpdatedObservedEvent(updated);
|
|
3178
|
+
return {
|
|
3179
|
+
status: 'updated',
|
|
3180
|
+
conversation: updated,
|
|
3181
|
+
reason: null,
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
private async refreshThreadTitleForRevision(sessionId: string, revision: number): Promise<void> {
|
|
3186
|
+
await this.refreshThreadTitle(sessionId, revision);
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
private publishConversationUpdatedObservedEvent(
|
|
3190
|
+
conversation: ControlPlaneConversationRecord,
|
|
3191
|
+
): void {
|
|
3192
|
+
this.publishObservedEvent(
|
|
3193
|
+
{
|
|
3194
|
+
tenantId: conversation.tenantId,
|
|
3195
|
+
userId: conversation.userId,
|
|
3196
|
+
workspaceId: conversation.workspaceId,
|
|
3197
|
+
directoryId: conversation.directoryId,
|
|
3198
|
+
conversationId: conversation.conversationId,
|
|
3199
|
+
},
|
|
3200
|
+
{
|
|
3201
|
+
type: 'conversation-updated',
|
|
3202
|
+
conversation: this.conversationRecord(conversation),
|
|
3203
|
+
},
|
|
3204
|
+
);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
2777
3207
|
private setSessionStatus(
|
|
2778
3208
|
state: SessionState,
|
|
2779
3209
|
status: StreamSessionRuntimeStatus,
|
|
@@ -3176,6 +3606,8 @@ export class ControlPlaneStreamServer {
|
|
|
3176
3606
|
|
|
3177
3607
|
this.sessions.delete(sessionId);
|
|
3178
3608
|
this.launchCommandBySessionId.delete(sessionId);
|
|
3609
|
+
this.threadTitleRevisionBySessionId.delete(sessionId);
|
|
3610
|
+
this.threadTitleRefreshBySessionId.delete(sessionId);
|
|
3179
3611
|
for (const [token, mappedSessionId] of this.telemetryTokenToSessionId.entries()) {
|
|
3180
3612
|
if (mappedSessionId === sessionId) {
|
|
3181
3613
|
this.telemetryTokenToSessionId.delete(token);
|