@modelprofile.com/flexharness 3.6.0 → 3.8.0
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/changelog.md +18 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +71 -1
- package/dist_ts/classes.flexharness.js +2374 -138
- package/dist_ts/classes.stores.js +1 -1
- package/dist_ts/errors.d.ts +8 -0
- package/dist_ts/errors.js +17 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.d.ts +272 -5
- package/dist_ts/interfaces.js +18 -2
- package/dist_ts/plugins.d.ts +2 -2
- package/dist_ts/plugins.js +3 -3
- package/dist_ts/utils.json.d.ts +5 -2
- package/dist_ts/utils.json.js +485 -10
- package/dist_ts/utils.slashcommands.d.ts +7 -0
- package/dist_ts/utils.slashcommands.js +164 -0
- package/dist_ts_migration/v3_legacyflexharness.js +6 -2
- package/package.json +1 -1
- package/readme.hints.md +4 -0
- package/readme.md +190 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +3210 -190
- package/ts/classes.stores.ts +9 -8
- package/ts/errors.ts +25 -0
- package/ts/index.ts +4 -0
- package/ts/interfaces.ts +378 -5
- package/ts/plugins.ts +2 -0
- package/ts/utils.json.ts +543 -12
- package/ts/utils.slashcommands.ts +178 -0
- package/ts_migration/v3_legacyflexharness.ts +10 -5
|
@@ -10,14 +10,26 @@ import {
|
|
|
10
10
|
FlexHarnessQueueFullError,
|
|
11
11
|
FlexHarnessRunError,
|
|
12
12
|
FlexHarnessSessionBusyError,
|
|
13
|
+
FlexHarnessSlashCommandReentryError,
|
|
14
|
+
FlexHarnessSlashCommandUnavailableError,
|
|
13
15
|
FlexHarnessStoreConflictError,
|
|
14
16
|
FlexHarnessStoreCommitUncertainError,
|
|
15
17
|
FlexHarnessValidationError,
|
|
16
18
|
errorToInfo,
|
|
17
19
|
} from './errors.js';
|
|
20
|
+
import {
|
|
21
|
+
FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
|
|
22
|
+
FLEX_REVERSION_DEFAULT_LIMITS,
|
|
23
|
+
FLEX_REVERSION_MAXIMUM_LIMITS,
|
|
24
|
+
FLEX_REVERSION_REASON_CODE_MAX_BYTES,
|
|
25
|
+
FLEX_REVERSION_REFERENCE_MAX_BYTES,
|
|
26
|
+
FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
|
|
27
|
+
FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
|
|
28
|
+
} from './interfaces.js';
|
|
18
29
|
import type {
|
|
19
30
|
IFlexAgentContextInvocation,
|
|
20
31
|
IFlexAgentSessionPolicy,
|
|
32
|
+
IFlexAffectedWorkspace,
|
|
21
33
|
IFlexAttachmentMessagePart,
|
|
22
34
|
IFlexBackgroundExecution,
|
|
23
35
|
IFlexCallbackLimits,
|
|
@@ -41,7 +53,11 @@ import type {
|
|
|
41
53
|
IFlexPromptQueueLimits,
|
|
42
54
|
IFlexPromptOptions,
|
|
43
55
|
IFlexPromptResult,
|
|
44
|
-
|
|
56
|
+
IFlexProjectionSnapshotCurrent,
|
|
57
|
+
IFlexReversionLimits,
|
|
58
|
+
IFlexReversionSegment,
|
|
59
|
+
IFlexPendingReversionRelease,
|
|
60
|
+
IFlexRedoSessionResult,
|
|
45
61
|
IFlexResolvedModel,
|
|
46
62
|
IFlexResolvedScope,
|
|
47
63
|
IFlexResourceToolProviderDescriptor,
|
|
@@ -50,13 +66,20 @@ import type {
|
|
|
50
66
|
IFlexSchedulePromptOptions,
|
|
51
67
|
IFlexScopeSnapshot,
|
|
52
68
|
IFlexSession,
|
|
69
|
+
IFlexSessionReversionGroup,
|
|
70
|
+
IFlexSessionReversionInfo,
|
|
53
71
|
IFlexSessionTombstone,
|
|
72
|
+
IFlexSlashCommandDescriptor,
|
|
73
|
+
IFlexSlashCommandExecutionOptions,
|
|
74
|
+
IFlexSlashCommandHandlerRegistration,
|
|
75
|
+
IFlexSlashCommandTemplateRegistration,
|
|
54
76
|
IFlexSubagentDefinition,
|
|
55
77
|
IFlexTerminalProjection,
|
|
56
78
|
IFlexToolHandle,
|
|
57
79
|
IFlexToolMessagePart,
|
|
58
80
|
IFlexToolProviderContext,
|
|
59
81
|
IFlexUncertainToolExecution,
|
|
82
|
+
IFlexUndoSessionResult,
|
|
60
83
|
IFlexUpdateSessionOptions,
|
|
61
84
|
IFlexUsage,
|
|
62
85
|
IJsonObject,
|
|
@@ -73,7 +96,15 @@ import type {
|
|
|
73
96
|
TFlexPrompt,
|
|
74
97
|
TFlexPromptPart,
|
|
75
98
|
TFlexPromptQueueStatus,
|
|
99
|
+
TFlexSlashCommandExecutionResult,
|
|
100
|
+
TFlexSlashCommandRegistration,
|
|
101
|
+
TFlexPendingReversion,
|
|
102
|
+
TFlexProjectionSnapshot,
|
|
103
|
+
TFlexReversionPolicy,
|
|
104
|
+
TFlexSessionReversionGroupKind,
|
|
105
|
+
TFlexTurnReversionFinalizedOutcome,
|
|
76
106
|
TFlexToolExecutionReconciliation,
|
|
107
|
+
TJsonValue,
|
|
77
108
|
} from './interfaces.js';
|
|
78
109
|
import { InMemoryFlexHarnessStores } from './classes.stores.js';
|
|
79
110
|
import {
|
|
@@ -88,6 +119,14 @@ import {
|
|
|
88
119
|
normalizeFlexPrompt,
|
|
89
120
|
type INormalizedFlexPrompt,
|
|
90
121
|
} from './utils.prompt.js';
|
|
122
|
+
import {
|
|
123
|
+
FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE,
|
|
124
|
+
FLEX_SLASH_COMMAND_MAX_INPUT_BYTES,
|
|
125
|
+
expandSlashCommandTemplate,
|
|
126
|
+
isValidSlashCommandName,
|
|
127
|
+
parseSlashCommand,
|
|
128
|
+
slashCommandTemplateHints,
|
|
129
|
+
} from './utils.slashcommands.js';
|
|
91
130
|
|
|
92
131
|
type TCanonicalOutcome = 'accepted' | 'rejected' | 'interrupted';
|
|
93
132
|
type TRunPhase =
|
|
@@ -104,6 +143,14 @@ interface IStoredSessionState {
|
|
|
104
143
|
session: IFlexSession;
|
|
105
144
|
messages: IFlexMessage[];
|
|
106
145
|
stagedTerminals: IFlexTerminalProjection[];
|
|
146
|
+
reversionSegments: IFlexReversionSegment[];
|
|
147
|
+
revertCursor: number;
|
|
148
|
+
excludedRunIds: string[];
|
|
149
|
+
pendingReversion?: TFlexPendingReversion;
|
|
150
|
+
pendingReversionReleases: IFlexPendingReversionRelease[];
|
|
151
|
+
projectionSchemaVersion: 1 | 2 | 3;
|
|
152
|
+
projectionBaseline?: TFlexProjectionSnapshot;
|
|
153
|
+
projectionReconciliationRequired: boolean;
|
|
107
154
|
projectionRevision: number;
|
|
108
155
|
projectionQueue: Promise<void>;
|
|
109
156
|
rememberedPermissionKeys: Set<string>;
|
|
@@ -131,6 +178,18 @@ interface IRetainedSessionCleanup {
|
|
|
131
178
|
domainsCompleted: boolean;
|
|
132
179
|
}
|
|
133
180
|
|
|
181
|
+
interface IReversionGroup {
|
|
182
|
+
target: IFlexReversionSegment;
|
|
183
|
+
segments: IFlexReversionSegment[];
|
|
184
|
+
kind: TFlexSessionReversionGroupKind;
|
|
185
|
+
affectedWorkspaces: IFlexAffectedWorkspace[];
|
|
186
|
+
affectedWorkspacesTruncated: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
type TNormalizedCaptureInspection =
|
|
190
|
+
| { status: 'missing' | 'prepared' | 'unknown' }
|
|
191
|
+
| { status: 'finalized'; outcome: TFlexTurnReversionFinalizedOutcome };
|
|
192
|
+
|
|
134
193
|
interface IDetachedCleanup {
|
|
135
194
|
cleanup: () => Promise<void> | void;
|
|
136
195
|
projectError: (error: unknown) => Error;
|
|
@@ -151,6 +210,13 @@ interface IOrphanedTombstoneCleanup {
|
|
|
151
210
|
completion: Promise<void>;
|
|
152
211
|
}
|
|
153
212
|
|
|
213
|
+
interface IOrphanedTombstoneOwner<TScope> {
|
|
214
|
+
state: IStorageState;
|
|
215
|
+
rootSessionId: string;
|
|
216
|
+
scopeId: string;
|
|
217
|
+
scope: TScope;
|
|
218
|
+
}
|
|
219
|
+
|
|
154
220
|
interface IOrphanedExecutionContextOwner {
|
|
155
221
|
storageKey: string;
|
|
156
222
|
sessionId: string;
|
|
@@ -160,6 +226,7 @@ interface IStorageState {
|
|
|
160
226
|
storageKey: string;
|
|
161
227
|
compactorLifecycleController: AbortController;
|
|
162
228
|
scopeIdHint: string;
|
|
229
|
+
scopeContext: { scopeId: string; scope: unknown };
|
|
163
230
|
revision: number;
|
|
164
231
|
sessions: Map<string, IStoredSessionState>;
|
|
165
232
|
retainedSessionCleanups: Map<string, IRetainedSessionCleanup>;
|
|
@@ -261,6 +328,38 @@ interface IScopeAdmissionState {
|
|
|
261
328
|
retiring: boolean;
|
|
262
329
|
}
|
|
263
330
|
|
|
331
|
+
type TRegisteredSlashCommand<TScope> =
|
|
332
|
+
| Readonly<IFlexSlashCommandTemplateRegistration>
|
|
333
|
+
| Readonly<IFlexSlashCommandHandlerRegistration<TScope>>;
|
|
334
|
+
|
|
335
|
+
interface IActiveSlashCommandExecution {
|
|
336
|
+
kind: 'handler' | 'operation' | 'prompt-admission';
|
|
337
|
+
storageKey: string;
|
|
338
|
+
sessionId: string;
|
|
339
|
+
controller: AbortController;
|
|
340
|
+
completion: Promise<unknown>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface IActiveSlashCommandListing {
|
|
344
|
+
storageKey: string;
|
|
345
|
+
sessionId: string;
|
|
346
|
+
controller: AbortController;
|
|
347
|
+
completion: Promise<unknown>;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface ISlashCommandInvocationOwner {
|
|
351
|
+
scopeId: string;
|
|
352
|
+
storageKey: string;
|
|
353
|
+
sessionId: string;
|
|
354
|
+
sessionKey: string;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface ISlashCommandActivityOwner {
|
|
358
|
+
scopeId: string;
|
|
359
|
+
storageKey: string;
|
|
360
|
+
sessionId: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
264
363
|
interface IRunResultProjection {
|
|
265
364
|
text: string;
|
|
266
365
|
steps: number;
|
|
@@ -342,9 +441,14 @@ const maximumMaxSubagentCallsPerRun = 128;
|
|
|
342
441
|
const maxResourceToolProviders = 128;
|
|
343
442
|
const maxResourceIdBytes = 512;
|
|
344
443
|
const maxResourceToolNameBytes = 512;
|
|
444
|
+
const maxSlashCommandRegistrations = 128;
|
|
445
|
+
const maxSlashCommandDescriptionBytes = 2048;
|
|
446
|
+
const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
|
|
345
447
|
const resourceToolStemLength = 16;
|
|
346
448
|
const repairCancellationMessage = 'The process stopped before this run completed.';
|
|
347
449
|
const scopeRetirementMessage = 'The scope is being retired.';
|
|
450
|
+
const slashCommandSessionUnavailableReason = 'Session must be idle with no queued prompts.';
|
|
451
|
+
const reservedSlashCommandNames = new Set(['compact', 'undo', 'redo', 'init']);
|
|
348
452
|
const externalErrorFallback: IFlexErrorInfo = Object.freeze({
|
|
349
453
|
name: 'FlexHarnessExternalError',
|
|
350
454
|
message: 'The model operation failed.',
|
|
@@ -559,6 +663,89 @@ function normalizeSubagents(
|
|
|
559
663
|
return Object.freeze(normalized);
|
|
560
664
|
}
|
|
561
665
|
|
|
666
|
+
function normalizeSlashCommands<TScope>(
|
|
667
|
+
registrations: readonly TFlexSlashCommandRegistration<TScope>[] | undefined,
|
|
668
|
+
): ReadonlyMap<string, TRegisteredSlashCommand<TScope>> {
|
|
669
|
+
if (registrations === undefined) return new Map();
|
|
670
|
+
if (!Array.isArray(registrations) || registrations.length > maxSlashCommandRegistrations) {
|
|
671
|
+
throw new FlexHarnessValidationError(
|
|
672
|
+
`slashCommands must be an array with at most ${maxSlashCommandRegistrations} registrations.`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
const normalized = new Map<string, TRegisteredSlashCommand<TScope>>();
|
|
676
|
+
for (let index = 0; index < registrations.length; index++) {
|
|
677
|
+
const registration = registrations[index];
|
|
678
|
+
if (
|
|
679
|
+
!registration
|
|
680
|
+
|| typeof registration !== 'object'
|
|
681
|
+
|| Array.isArray(registration)
|
|
682
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(registration))
|
|
683
|
+
) {
|
|
684
|
+
throw new FlexHarnessValidationError(`slashCommands[${index}] must be a plain object.`);
|
|
685
|
+
}
|
|
686
|
+
const keys = Object.keys(registration);
|
|
687
|
+
const hasTemplate = Object.prototype.hasOwnProperty.call(registration, 'template');
|
|
688
|
+
const hasHandler = Object.prototype.hasOwnProperty.call(registration, 'handler');
|
|
689
|
+
const supported = hasTemplate
|
|
690
|
+
? ['name', 'description', 'template']
|
|
691
|
+
: ['name', 'description', 'handler'];
|
|
692
|
+
const unsupported = keys.find((key) => !supported.includes(key));
|
|
693
|
+
if (unsupported || hasTemplate === hasHandler) {
|
|
694
|
+
throw new FlexHarnessValidationError(
|
|
695
|
+
`slashCommands[${index}] must define exactly one of template or handler.`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
if (typeof registration.name !== 'string' || !isValidSlashCommandName(registration.name)) {
|
|
699
|
+
throw new FlexHarnessValidationError(`slashCommands[${index}].name is invalid.`);
|
|
700
|
+
}
|
|
701
|
+
if (reservedSlashCommandNames.has(registration.name)) {
|
|
702
|
+
throw new FlexHarnessValidationError(
|
|
703
|
+
`Slash command "${registration.name}" is reserved.`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (normalized.has(registration.name)) {
|
|
707
|
+
throw new FlexHarnessValidationError(`Duplicate slash command "${registration.name}".`);
|
|
708
|
+
}
|
|
709
|
+
if (registration.description !== undefined) {
|
|
710
|
+
validateUtf8String(
|
|
711
|
+
registration.description,
|
|
712
|
+
`slashCommands[${index}].description`,
|
|
713
|
+
maxSlashCommandDescriptionBytes,
|
|
714
|
+
true,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (hasTemplate) {
|
|
718
|
+
const template = registration.template;
|
|
719
|
+
validateUtf8String(
|
|
720
|
+
template,
|
|
721
|
+
`slashCommands[${index}].template`,
|
|
722
|
+
maxSlashCommandTemplateBytes,
|
|
723
|
+
true,
|
|
724
|
+
);
|
|
725
|
+
normalized.set(registration.name, Object.freeze({
|
|
726
|
+
name: registration.name,
|
|
727
|
+
...(registration.description === undefined
|
|
728
|
+
? {}
|
|
729
|
+
: { description: registration.description }),
|
|
730
|
+
template,
|
|
731
|
+
}));
|
|
732
|
+
} else {
|
|
733
|
+
const handler = registration.handler;
|
|
734
|
+
if (typeof handler !== 'function') {
|
|
735
|
+
throw new FlexHarnessValidationError(`slashCommands[${index}].handler must be a function.`);
|
|
736
|
+
}
|
|
737
|
+
normalized.set(registration.name, Object.freeze({
|
|
738
|
+
name: registration.name,
|
|
739
|
+
...(registration.description === undefined
|
|
740
|
+
? {}
|
|
741
|
+
: { description: registration.description }),
|
|
742
|
+
handler,
|
|
743
|
+
}));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return Object.freeze(normalized);
|
|
747
|
+
}
|
|
748
|
+
|
|
562
749
|
function resolveBoundedPositiveInteger(
|
|
563
750
|
value: number | undefined,
|
|
564
751
|
name: string,
|
|
@@ -833,6 +1020,31 @@ function validatePromptOptions(options: IFlexPromptOptions, scheduled: boolean):
|
|
|
833
1020
|
}
|
|
834
1021
|
}
|
|
835
1022
|
|
|
1023
|
+
function validateSlashCommandExecutionOptions(
|
|
1024
|
+
options: IFlexSlashCommandExecutionOptions,
|
|
1025
|
+
): IFlexPromptOptions {
|
|
1026
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
1027
|
+
throw new FlexHarnessValidationError('Slash command execution options must be a plain object.');
|
|
1028
|
+
}
|
|
1029
|
+
const unsupported = Object.keys(options)
|
|
1030
|
+
.find((key) => !['modelHint', 'system', 'maxSteps', 'signal'].includes(key));
|
|
1031
|
+
if (unsupported) {
|
|
1032
|
+
throw new FlexHarnessValidationError(
|
|
1033
|
+
`Slash command execution options do not support "${unsupported}".`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
|
|
1037
|
+
throw new FlexHarnessValidationError('Slash command signal must be an AbortSignal.');
|
|
1038
|
+
}
|
|
1039
|
+
const promptOptions: IFlexPromptOptions = {
|
|
1040
|
+
...(options.modelHint === undefined ? {} : { modelHint: options.modelHint }),
|
|
1041
|
+
...(options.system === undefined ? {} : { system: options.system }),
|
|
1042
|
+
...(options.maxSteps === undefined ? {} : { maxSteps: options.maxSteps }),
|
|
1043
|
+
};
|
|
1044
|
+
validatePromptOptions(promptOptions, false);
|
|
1045
|
+
return promptOptions;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
836
1048
|
function resolveCallbackLimits(limits: IFlexCallbackLimits = {}): Required<IFlexCallbackLimits> {
|
|
837
1049
|
const resolved = {
|
|
838
1050
|
maxEvents: limits.maxEvents ?? DEFAULT_CALLBACK_LIMITS.maxEvents,
|
|
@@ -870,6 +1082,27 @@ function resolvePromptQueueLimits(
|
|
|
870
1082
|
return resolved;
|
|
871
1083
|
}
|
|
872
1084
|
|
|
1085
|
+
function resolveReversionLimits(
|
|
1086
|
+
limits: IFlexReversionLimits = {},
|
|
1087
|
+
): Required<IFlexReversionLimits> {
|
|
1088
|
+
const resolved = {
|
|
1089
|
+
maxCompletedTurns: limits.maxCompletedTurns ?? FLEX_REVERSION_DEFAULT_LIMITS.maxCompletedTurns,
|
|
1090
|
+
maxSegments: limits.maxSegments ?? FLEX_REVERSION_DEFAULT_LIMITS.maxSegments,
|
|
1091
|
+
maxExcludedRunIds: limits.maxExcludedRunIds ?? FLEX_REVERSION_DEFAULT_LIMITS.maxExcludedRunIds,
|
|
1092
|
+
maxPendingReversionReleases: limits.maxPendingReversionReleases
|
|
1093
|
+
?? FLEX_REVERSION_DEFAULT_LIMITS.maxPendingReversionReleases,
|
|
1094
|
+
};
|
|
1095
|
+
for (const [name, value] of Object.entries(resolved)) {
|
|
1096
|
+
const maximum = FLEX_REVERSION_MAXIMUM_LIMITS[name as keyof IFlexReversionLimits];
|
|
1097
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
1098
|
+
throw new FlexHarnessValidationError(
|
|
1099
|
+
`reversionLimits.${name} must be an integer from 1 through ${maximum}.`,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return resolved;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
873
1106
|
function normalizeAgentSessionPolicy<TScope>(
|
|
874
1107
|
policy: IFlexAgentSessionPolicy<TScope> = {},
|
|
875
1108
|
): IFlexAgentSessionPolicy<TScope> {
|
|
@@ -906,8 +1139,12 @@ export class FlexHarness<TScope = unknown> {
|
|
|
906
1139
|
private readonly toolOutputLimits: Required<NonNullable<IFlexHarnessOptions<TScope>['toolOutputLimits']>>;
|
|
907
1140
|
private readonly callbackLimits: Required<IFlexCallbackLimits>;
|
|
908
1141
|
private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
|
|
1142
|
+
private readonly reversionLimits: Required<IFlexReversionLimits>;
|
|
1143
|
+
private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
|
|
1144
|
+
private readonly reversionPolicy: TFlexReversionPolicy;
|
|
909
1145
|
private readonly externalErrorProjector?: TFlexExternalErrorProjector;
|
|
910
1146
|
private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
|
|
1147
|
+
private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
|
|
911
1148
|
private readonly maxSubagentDepth: number;
|
|
912
1149
|
private readonly maxSubagentCallsPerRun: number;
|
|
913
1150
|
private readonly stateLoads = new Map<string, Promise<IStorageState>>();
|
|
@@ -925,10 +1162,19 @@ export class FlexHarness<TScope = unknown> {
|
|
|
925
1162
|
>();
|
|
926
1163
|
private readonly orphanedProviderReleases = new Map<string, IOrphanedProviderRelease>();
|
|
927
1164
|
private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
|
|
1165
|
+
private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
|
|
928
1166
|
private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
|
|
1167
|
+
private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
|
|
1168
|
+
private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
|
|
929
1169
|
private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
|
|
930
1170
|
IFlexAgentContextInvocation<TScope>
|
|
931
1171
|
>();
|
|
1172
|
+
private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
|
|
1173
|
+
ISlashCommandInvocationOwner
|
|
1174
|
+
>();
|
|
1175
|
+
private readonly slashCommandActivityContext = new plugins.AsyncLocalStorage<
|
|
1176
|
+
ISlashCommandActivityOwner
|
|
1177
|
+
>();
|
|
932
1178
|
private readonly deferredCompactorContexts = new WeakMap<
|
|
933
1179
|
IFlexAgentContextInvocation<unknown>,
|
|
934
1180
|
IFlexAgentContextInvocation<unknown>
|
|
@@ -954,8 +1200,29 @@ export class FlexHarness<TScope = unknown> {
|
|
|
954
1200
|
this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
|
|
955
1201
|
this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
|
|
956
1202
|
this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
|
|
1203
|
+
this.reversionLimits = resolveReversionLimits(options.reversionLimits);
|
|
1204
|
+
this.turnReversionProvider = options.turnReversionProvider;
|
|
1205
|
+
this.reversionPolicy = options.reversionPolicy ?? 'transcript-optional';
|
|
1206
|
+
if (!['transcript-optional', 'workspace-required'].includes(this.reversionPolicy)) {
|
|
1207
|
+
throw new FlexHarnessValidationError(
|
|
1208
|
+
'reversionPolicy must be "transcript-optional" or "workspace-required".',
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
const protocolVersion = this.turnReversionProvider
|
|
1212
|
+
&& 'protocolVersion' in this.turnReversionProvider
|
|
1213
|
+
? this.turnReversionProvider.protocolVersion
|
|
1214
|
+
: 1;
|
|
1215
|
+
if (protocolVersion !== 1 && protocolVersion !== 2) {
|
|
1216
|
+
throw new FlexHarnessValidationError('Turn reversion provider protocolVersion is invalid.');
|
|
1217
|
+
}
|
|
1218
|
+
if (this.reversionPolicy === 'workspace-required' && protocolVersion !== 2) {
|
|
1219
|
+
throw new FlexHarnessValidationError(
|
|
1220
|
+
'workspace-required reversion policy requires a protocolVersion 2 turn reversion provider.',
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
957
1223
|
this.externalErrorProjector = options.externalErrorProjector;
|
|
958
1224
|
this.subagents = normalizeSubagents(options.subagents);
|
|
1225
|
+
this.slashCommands = normalizeSlashCommands(options.slashCommands);
|
|
959
1226
|
this.maxSubagentDepth = resolveBoundedPositiveInteger(
|
|
960
1227
|
options.maxSubagentDepth,
|
|
961
1228
|
'maxSubagentDepth',
|
|
@@ -973,6 +1240,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
973
1240
|
public async listSessions(scopeId: string): Promise<IFlexSession[]> {
|
|
974
1241
|
const { state } = await this.resolveState(scopeId);
|
|
975
1242
|
await state.scopeQueue;
|
|
1243
|
+
this.assertOpen();
|
|
976
1244
|
this.assertStateAcceptingWork(state);
|
|
977
1245
|
return publicSnapshot([...state.sessions.values()]
|
|
978
1246
|
.filter((stored) => !state.initializingSessions.has(stored.session.sessionId))
|
|
@@ -1139,6 +1407,41 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1139
1407
|
return publicSnapshot(this.requireSession(state, sessionId).session);
|
|
1140
1408
|
}
|
|
1141
1409
|
|
|
1410
|
+
public async getSessionReversionInfo(
|
|
1411
|
+
scopeId: string,
|
|
1412
|
+
sessionId: string,
|
|
1413
|
+
): Promise<IFlexSessionReversionInfo> {
|
|
1414
|
+
const { state } = await this.resolveState(scopeId);
|
|
1415
|
+
const stored = this.requireSession(state, sessionId);
|
|
1416
|
+
await stored.projectionQueue;
|
|
1417
|
+
this.assertStateAcceptingWork(state);
|
|
1418
|
+
await this.reconcileContextAvailability(state, stored);
|
|
1419
|
+
const unavailableReason = await this.reversionUnavailableReason(state, stored);
|
|
1420
|
+
const groups = this.reversionGroups(stored);
|
|
1421
|
+
const undo = this.reversionUnit(stored, 'undo');
|
|
1422
|
+
const redo = this.reversionUnit(stored, 'redo');
|
|
1423
|
+
const isAvailable = (unit: ReturnType<typeof this.reversionUnit>) => Boolean(
|
|
1424
|
+
!unavailableReason
|
|
1425
|
+
&& unit
|
|
1426
|
+
&& unit.target.contextAvailable
|
|
1427
|
+
&& !unit.segments.some((segment) =>
|
|
1428
|
+
segment.provenance === 'workspace'
|
|
1429
|
+
&& segment.disposition === 'revertible'
|
|
1430
|
+
&& segment.workspaceReference === undefined),
|
|
1431
|
+
);
|
|
1432
|
+
return publicSnapshot({
|
|
1433
|
+
undoAvailable: isAvailable(undo),
|
|
1434
|
+
redoAvailable: isAvailable(redo),
|
|
1435
|
+
groups: groups.map((group, index): IFlexSessionReversionGroup => ({
|
|
1436
|
+
runId: group.target.runId,
|
|
1437
|
+
kind: group.kind,
|
|
1438
|
+
visibility: index < stored.revertCursor ? 'visible' : 'hidden',
|
|
1439
|
+
affectedWorkspaces: cloneSerializable(group.affectedWorkspaces),
|
|
1440
|
+
affectedWorkspacesTruncated: group.affectedWorkspacesTruncated,
|
|
1441
|
+
})),
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1142
1445
|
public async updateSession(
|
|
1143
1446
|
scopeId: string,
|
|
1144
1447
|
sessionId: string,
|
|
@@ -1166,7 +1469,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1166
1469
|
}
|
|
1167
1470
|
|
|
1168
1471
|
public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
|
|
1472
|
+
this.assertNoAmbientSlashCommandTeardown({ scopeId, sessionId });
|
|
1169
1473
|
const { scope, state } = await this.resolveState(scopeId);
|
|
1474
|
+
this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
|
|
1170
1475
|
validateIdentifier(sessionId, 'sessionId');
|
|
1171
1476
|
await state.scopeQueue;
|
|
1172
1477
|
const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
|
|
@@ -1241,6 +1546,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1241
1546
|
}
|
|
1242
1547
|
});
|
|
1243
1548
|
const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
|
|
1549
|
+
const slashCommandErrors: unknown[] = [];
|
|
1550
|
+
for (const deleted of deletedSessions) {
|
|
1551
|
+
const listingSettlement = this.abortSlashCommandListings(
|
|
1552
|
+
state.storageKey,
|
|
1553
|
+
reason,
|
|
1554
|
+
deleted.sessionId,
|
|
1555
|
+
);
|
|
1556
|
+
if (listingSettlement) await listingSettlement;
|
|
1557
|
+
await this.abortSlashCommandExecutions(
|
|
1558
|
+
state.storageKey,
|
|
1559
|
+
reason,
|
|
1560
|
+
slashCommandErrors,
|
|
1561
|
+
deleted.sessionId,
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1244
1564
|
const childFirstDeletedSessions = [...deletedSessions].sort((left, right) =>
|
|
1245
1565
|
(right.depth ?? 0) - (left.depth ?? 0)
|
|
1246
1566
|
|| left.sessionId.localeCompare(right.sessionId));
|
|
@@ -1255,7 +1575,10 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1255
1575
|
}
|
|
1256
1576
|
}
|
|
1257
1577
|
try {
|
|
1258
|
-
const orphanErrors =
|
|
1578
|
+
const orphanErrors = [
|
|
1579
|
+
...slashCommandErrors,
|
|
1580
|
+
...await this.closeOrphanedResources(state.storageKey),
|
|
1581
|
+
];
|
|
1259
1582
|
if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
|
|
1260
1583
|
for (const descendantRoot of descendantRoots) {
|
|
1261
1584
|
await this.finishTombstoneCleanup(
|
|
@@ -1282,7 +1605,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1282
1605
|
const stored = this.requireSession(state, sessionId);
|
|
1283
1606
|
await stored.projectionQueue;
|
|
1284
1607
|
this.assertStateAcceptingWork(state);
|
|
1285
|
-
return publicSnapshot(stored
|
|
1608
|
+
return publicSnapshot(this.visibleMessages(stored));
|
|
1286
1609
|
}
|
|
1287
1610
|
|
|
1288
1611
|
public async listMessagePage(
|
|
@@ -1312,20 +1635,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1312
1635
|
await stored.projectionQueue;
|
|
1313
1636
|
this.assertStateAcceptingWork(state);
|
|
1314
1637
|
const namespace = this.messageCursorNamespace(state.storageKey);
|
|
1315
|
-
|
|
1638
|
+
const visibleMessages = this.visibleMessages(stored);
|
|
1639
|
+
let end = visibleMessages.length;
|
|
1316
1640
|
if (options.before !== undefined) {
|
|
1317
1641
|
const cursor = this.parseMessageCursor(options.before);
|
|
1318
1642
|
if (cursor.namespace !== namespace || cursor.sessionId !== sessionId) {
|
|
1319
1643
|
throw new FlexHarnessValidationError('Message page cursor is invalid.');
|
|
1320
1644
|
}
|
|
1321
|
-
const anchor =
|
|
1645
|
+
const anchor = visibleMessages.findIndex((message) => message.messageId === cursor.anchorMessageId);
|
|
1322
1646
|
if (anchor < 0) throw new FlexHarnessValidationError('Message page cursor is stale.');
|
|
1323
1647
|
end = anchor;
|
|
1324
1648
|
}
|
|
1325
1649
|
let start = end;
|
|
1326
1650
|
let messages: IFlexMessage[] = [];
|
|
1327
1651
|
for (let index = end - 1; index >= 0 && messages.length < limit; index--) {
|
|
1328
|
-
const candidate = createBoundedTransferMessage(
|
|
1652
|
+
const candidate = createBoundedTransferMessage(visibleMessages[index]);
|
|
1329
1653
|
const candidateMessages = [candidate, ...messages];
|
|
1330
1654
|
const nextCursor = index > 0
|
|
1331
1655
|
? this.createMessageCursor(namespace, sessionId, candidate.messageId)
|
|
@@ -1357,88 +1681,624 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1357
1681
|
return publicSnapshot(createBoundedTransferMessage(this.requireMessage(stored, messageId)));
|
|
1358
1682
|
}
|
|
1359
1683
|
|
|
1360
|
-
public async
|
|
1361
|
-
scopeId: string,
|
|
1362
|
-
sessionId: string,
|
|
1363
|
-
prompt: TFlexPrompt,
|
|
1364
|
-
options: IFlexPromptOptions = {},
|
|
1365
|
-
): Promise<IFlexPromptResult> {
|
|
1366
|
-
const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
|
|
1367
|
-
return admission.completion;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
public async startPrompt(
|
|
1684
|
+
public async listSlashCommands(
|
|
1371
1685
|
scopeId: string,
|
|
1372
1686
|
sessionId: string,
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1687
|
+
): Promise<IFlexSlashCommandDescriptor[]> {
|
|
1688
|
+
const { state } = await this.resolveState(scopeId);
|
|
1689
|
+
return this.trackSlashCommandListing(scopeId, state, sessionId, async (signal) => {
|
|
1690
|
+
await this.awaitReversionOperation(state.scopeQueue, signal);
|
|
1691
|
+
signal.throwIfAborted();
|
|
1692
|
+
this.assertStateAcceptingWork(state);
|
|
1693
|
+
const stored = this.requireSession(state, sessionId);
|
|
1694
|
+
await this.reconcileContextAvailability(state, stored);
|
|
1695
|
+
signal.throwIfAborted();
|
|
1696
|
+
const sessionAvailable = this.isSessionAvailableForSlashCommand(state, stored);
|
|
1697
|
+
const reversionIdleReason = await this.reversionUnavailableReason(
|
|
1698
|
+
state,
|
|
1699
|
+
stored,
|
|
1700
|
+
false,
|
|
1701
|
+
signal,
|
|
1702
|
+
);
|
|
1703
|
+
signal.throwIfAborted();
|
|
1704
|
+
return this.createSlashCommandDescriptors(
|
|
1705
|
+
state,
|
|
1706
|
+
stored,
|
|
1707
|
+
sessionAvailable,
|
|
1708
|
+
reversionIdleReason,
|
|
1709
|
+
);
|
|
1710
|
+
});
|
|
1379
1711
|
}
|
|
1380
1712
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
):
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1713
|
+
private createSlashCommandDescriptors(
|
|
1714
|
+
state: IStorageState,
|
|
1715
|
+
stored: IStoredSessionState,
|
|
1716
|
+
sessionAvailable: boolean,
|
|
1717
|
+
reversionIdleReason?: string,
|
|
1718
|
+
): IFlexSlashCommandDescriptor[] {
|
|
1719
|
+
const sessionAvailability = sessionAvailable
|
|
1720
|
+
? {}
|
|
1721
|
+
: { available: false, unavailableReason: slashCommandSessionUnavailableReason };
|
|
1722
|
+
const undoUnit = this.reversionUnit(stored, 'undo');
|
|
1723
|
+
const redoUnit = this.reversionUnit(stored, 'redo');
|
|
1724
|
+
const workspaceReversion = (unit: ReturnType<typeof this.reversionUnit>) =>
|
|
1725
|
+
!unit
|
|
1726
|
+
|| !this.turnReversionProvider
|
|
1727
|
+
|| unit.segments.some((segment) => segment.provenance !== 'workspace')
|
|
1728
|
+
? 'unsupported' as const
|
|
1729
|
+
: 'supported' as const;
|
|
1730
|
+
const reversionAvailability = (unit: ReturnType<typeof this.reversionUnit>) => {
|
|
1731
|
+
if (reversionIdleReason) return {
|
|
1732
|
+
available: false,
|
|
1733
|
+
unavailableReason: reversionIdleReason,
|
|
1734
|
+
};
|
|
1735
|
+
if (!unit) return { available: false, unavailableReason: 'No turn is available.' };
|
|
1736
|
+
if (!unit.target.contextAvailable) return {
|
|
1737
|
+
available: false,
|
|
1738
|
+
unavailableReason: 'The turn is beyond the context archive horizon.',
|
|
1739
|
+
};
|
|
1740
|
+
if (unit.segments.some((segment) =>
|
|
1741
|
+
segment.disposition === 'revertible' && segment.workspaceReference === undefined)) return {
|
|
1742
|
+
available: false,
|
|
1743
|
+
unavailableReason: 'The turn has no available workspace reversion capture.',
|
|
1744
|
+
};
|
|
1745
|
+
return { available: true };
|
|
1746
|
+
};
|
|
1747
|
+
const compactAvailable = Boolean(this.agentSessionPolicy.contextCompactor) && sessionAvailable;
|
|
1748
|
+
const compactUnavailableReason = !this.agentSessionPolicy.contextCompactor
|
|
1749
|
+
? 'No context compactor is configured.'
|
|
1750
|
+
: !sessionAvailable
|
|
1751
|
+
? slashCommandSessionUnavailableReason
|
|
1752
|
+
: undefined;
|
|
1753
|
+
const initPrompt = expandSlashCommandTemplate(FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE, '', []);
|
|
1754
|
+
const initAvailability = this.slashCommandPromptAvailability(
|
|
1755
|
+
state,
|
|
1756
|
+
stored,
|
|
1757
|
+
this.promptAdmissionByteSize(initPrompt, {}),
|
|
1758
|
+
);
|
|
1759
|
+
const descriptors: IFlexSlashCommandDescriptor[] = [
|
|
1760
|
+
{
|
|
1761
|
+
name: 'compact',
|
|
1762
|
+
description: 'Compact the current session context.',
|
|
1763
|
+
kind: 'builtin',
|
|
1764
|
+
hints: [],
|
|
1765
|
+
available: compactAvailable,
|
|
1766
|
+
...(compactUnavailableReason === undefined
|
|
1767
|
+
? {}
|
|
1768
|
+
: { unavailableReason: compactUnavailableReason }),
|
|
1769
|
+
workspaceReversion: 'not-applicable',
|
|
1770
|
+
},
|
|
1771
|
+
{
|
|
1772
|
+
name: 'init',
|
|
1773
|
+
description: 'Create or update AGENTS.md for the active workspace.',
|
|
1774
|
+
kind: 'builtin',
|
|
1775
|
+
hints: ['$ARGUMENTS'],
|
|
1776
|
+
...initAvailability,
|
|
1777
|
+
workspaceReversion: 'not-applicable',
|
|
1778
|
+
},
|
|
1779
|
+
{
|
|
1780
|
+
name: 'undo',
|
|
1781
|
+
description: 'Undo the most recent session operation.',
|
|
1782
|
+
kind: 'builtin',
|
|
1783
|
+
hints: [],
|
|
1784
|
+
...reversionAvailability(undoUnit),
|
|
1785
|
+
workspaceReversion: workspaceReversion(undoUnit),
|
|
1786
|
+
},
|
|
1787
|
+
{
|
|
1788
|
+
name: 'redo',
|
|
1789
|
+
description: 'Redo the most recently undone session operation.',
|
|
1790
|
+
kind: 'builtin',
|
|
1791
|
+
hints: [],
|
|
1792
|
+
...reversionAvailability(redoUnit),
|
|
1793
|
+
workspaceReversion: workspaceReversion(redoUnit),
|
|
1794
|
+
},
|
|
1795
|
+
];
|
|
1796
|
+
for (const registration of this.slashCommands.values()) {
|
|
1797
|
+
const template = typeof registration.template === 'string';
|
|
1798
|
+
const availability = template
|
|
1799
|
+
? this.slashCommandPromptAvailability(
|
|
1800
|
+
state,
|
|
1801
|
+
stored,
|
|
1802
|
+
this.promptAdmissionByteSize(
|
|
1803
|
+
expandSlashCommandTemplate(registration.template!, '', []),
|
|
1804
|
+
{},
|
|
1805
|
+
),
|
|
1806
|
+
)
|
|
1807
|
+
: { available: sessionAvailable, ...sessionAvailability };
|
|
1808
|
+
descriptors.push({
|
|
1809
|
+
name: registration.name,
|
|
1810
|
+
description: registration.description ?? '',
|
|
1811
|
+
kind: template ? 'template' : 'handler',
|
|
1812
|
+
hints: template ? slashCommandTemplateHints(registration.template!) : [],
|
|
1813
|
+
...availability,
|
|
1814
|
+
workspaceReversion: 'not-applicable',
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
return publicSnapshot(descriptors);
|
|
1390
1818
|
}
|
|
1391
1819
|
|
|
1392
|
-
public async
|
|
1820
|
+
public async executeSlashCommand(
|
|
1393
1821
|
scopeId: string,
|
|
1394
1822
|
sessionId: string,
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const
|
|
1403
|
-
if (
|
|
1404
|
-
|
|
1405
|
-
|
|
1823
|
+
untouchedInput: string,
|
|
1824
|
+
options: IFlexSlashCommandExecutionOptions = {},
|
|
1825
|
+
): Promise<TFlexSlashCommandExecutionResult> {
|
|
1826
|
+
const currentInvocation = this.slashCommandInvocationContext.getStore();
|
|
1827
|
+
if (currentInvocation?.scopeId === scopeId && currentInvocation.sessionId === sessionId) {
|
|
1828
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
1829
|
+
}
|
|
1830
|
+
const parsed = parseSlashCommand(untouchedInput);
|
|
1831
|
+
if (parsed.type !== 'parsed') return parsed;
|
|
1832
|
+
const registration = this.slashCommands.get(parsed.name);
|
|
1833
|
+
if (!reservedSlashCommandNames.has(parsed.name) && !registration) {
|
|
1834
|
+
return Object.freeze({
|
|
1835
|
+
type: 'unknown',
|
|
1836
|
+
input: parsed.input,
|
|
1837
|
+
name: parsed.name,
|
|
1838
|
+
rawArguments: parsed.rawArguments,
|
|
1839
|
+
arguments: parsed.arguments,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
const promptOptions = validateSlashCommandExecutionOptions(options);
|
|
1843
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
1844
|
+
await state.scopeQueue;
|
|
1845
|
+
this.assertOpen();
|
|
1846
|
+
this.assertStateAcceptingWork(state);
|
|
1847
|
+
const stored = this.requireSession(state, sessionId);
|
|
1848
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
|
|
1849
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
1850
|
+
if (invocationOwner?.sessionKey === sessionKey) {
|
|
1851
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
1852
|
+
}
|
|
1853
|
+
if (this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
1854
|
+
throw new FlexHarnessSessionBusyError(
|
|
1855
|
+
sessionId,
|
|
1856
|
+
'already has an active slash command execution',
|
|
1406
1857
|
);
|
|
1407
1858
|
}
|
|
1408
|
-
|
|
1859
|
+
if (parsed.name === 'undo' || parsed.name === 'redo') {
|
|
1860
|
+
if (parsed.arguments.length > 0) {
|
|
1861
|
+
throw new FlexHarnessValidationError(`Slash command "/${parsed.name}" does not accept arguments.`);
|
|
1862
|
+
}
|
|
1863
|
+
await this.changeReversionCursorResolved(
|
|
1864
|
+
scopeId,
|
|
1865
|
+
sessionId,
|
|
1866
|
+
parsed.name,
|
|
1867
|
+
scope.scope,
|
|
1868
|
+
state,
|
|
1869
|
+
options.signal,
|
|
1870
|
+
);
|
|
1871
|
+
return Object.freeze({ type: 'operation', name: parsed.name });
|
|
1872
|
+
}
|
|
1873
|
+
if (parsed.name === 'compact') {
|
|
1874
|
+
if (parsed.arguments.length > 0) {
|
|
1875
|
+
throw new FlexHarnessValidationError('Slash command "/compact" does not accept arguments.');
|
|
1876
|
+
}
|
|
1877
|
+
if (!this.agentSessionPolicy.contextCompactor) {
|
|
1878
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
1879
|
+
parsed.name,
|
|
1880
|
+
'No context compactor is configured.',
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
|
|
1884
|
+
return this.trackSlashCommandExecution(
|
|
1885
|
+
scopeId,
|
|
1886
|
+
state,
|
|
1887
|
+
sessionId,
|
|
1888
|
+
'operation',
|
|
1889
|
+
options.signal,
|
|
1890
|
+
async (signal) => {
|
|
1891
|
+
await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
|
|
1892
|
+
await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
|
|
1893
|
+
return Object.freeze({ type: 'operation', name: parsed.name });
|
|
1894
|
+
},
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
if (options.signal?.aborted) {
|
|
1898
|
+
throw this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.'));
|
|
1899
|
+
}
|
|
1900
|
+
if (parsed.name === 'init' || typeof registration?.template === 'string') {
|
|
1901
|
+
const template = parsed.name === 'init'
|
|
1902
|
+
? FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE
|
|
1903
|
+
: registration!.template!;
|
|
1904
|
+
const expanded = expandSlashCommandTemplate(template, parsed.rawArguments, parsed.arguments);
|
|
1905
|
+
normalizeFlexPrompt(expanded);
|
|
1906
|
+
this.requireSlashCommandPromptAvailable(
|
|
1907
|
+
state,
|
|
1908
|
+
stored,
|
|
1909
|
+
parsed.name,
|
|
1910
|
+
this.promptAdmissionByteSize(expanded, promptOptions),
|
|
1911
|
+
);
|
|
1912
|
+
return this.trackSlashCommandExecution(
|
|
1913
|
+
scopeId,
|
|
1914
|
+
state,
|
|
1915
|
+
sessionId,
|
|
1916
|
+
'prompt-admission',
|
|
1917
|
+
options.signal,
|
|
1918
|
+
async (signal) => {
|
|
1919
|
+
const queued = await this.enqueuePromptInternal(
|
|
1920
|
+
scopeId,
|
|
1921
|
+
sessionId,
|
|
1922
|
+
expanded,
|
|
1923
|
+
promptOptions,
|
|
1924
|
+
undefined,
|
|
1925
|
+
undefined,
|
|
1926
|
+
false,
|
|
1927
|
+
signal,
|
|
1928
|
+
true,
|
|
1929
|
+
);
|
|
1930
|
+
const cancellation = this.trustInternalError(
|
|
1931
|
+
new FlexHarnessAbortError('The slash command prompt was aborted.'),
|
|
1932
|
+
);
|
|
1933
|
+
const cancel = () => {
|
|
1934
|
+
const entry = queued.queued;
|
|
1935
|
+
if (!entry) return;
|
|
1936
|
+
this.cancelQueuedPrompt(
|
|
1937
|
+
entry,
|
|
1938
|
+
cancellation,
|
|
1939
|
+
this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
|
|
1940
|
+
);
|
|
1941
|
+
};
|
|
1942
|
+
const cancellationSignals = options.signal && options.signal !== signal
|
|
1943
|
+
? [signal, options.signal]
|
|
1944
|
+
: [signal];
|
|
1945
|
+
for (const cancellationSignal of cancellationSignals) {
|
|
1946
|
+
cancellationSignal.addEventListener('abort', cancel, { once: true });
|
|
1947
|
+
}
|
|
1948
|
+
if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) cancel();
|
|
1949
|
+
let admission: IFlexPromptAdmission;
|
|
1950
|
+
try {
|
|
1951
|
+
admission = await queued.started;
|
|
1952
|
+
if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) {
|
|
1953
|
+
cancel();
|
|
1954
|
+
throw cancellationSignals.find((cancellationSignal) => cancellationSignal.aborted)?.reason
|
|
1955
|
+
?? cancellation;
|
|
1956
|
+
}
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
for (const cancellationSignal of cancellationSignals) {
|
|
1959
|
+
cancellationSignal.removeEventListener('abort', cancel);
|
|
1960
|
+
}
|
|
1961
|
+
throw error;
|
|
1962
|
+
}
|
|
1963
|
+
void admission.completion.finally(() => {
|
|
1964
|
+
for (const cancellationSignal of cancellationSignals) {
|
|
1965
|
+
cancellationSignal.removeEventListener('abort', cancel);
|
|
1966
|
+
}
|
|
1967
|
+
}).catch(() => undefined);
|
|
1968
|
+
return Object.freeze({ type: 'prompt-admission', name: parsed.name, admission });
|
|
1969
|
+
},
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
|
|
1973
|
+
return this.executeSlashCommandHandler(
|
|
1409
1974
|
scopeId,
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1975
|
+
scope.scope,
|
|
1976
|
+
state,
|
|
1977
|
+
stored,
|
|
1978
|
+
parsed.name,
|
|
1979
|
+
parsed.rawArguments,
|
|
1980
|
+
parsed.arguments,
|
|
1981
|
+
registration!.handler!,
|
|
1982
|
+
options.signal,
|
|
1415
1983
|
);
|
|
1416
|
-
const admission = await queued.started;
|
|
1417
|
-
return Object.freeze({ ...admission, scheduleKey });
|
|
1418
1984
|
}
|
|
1419
1985
|
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
):
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
|
|
1432
|
-
return publicSnapshot(entry);
|
|
1986
|
+
private isSessionAvailableForSlashCommand(
|
|
1987
|
+
state: IStorageState,
|
|
1988
|
+
stored: IStoredSessionState,
|
|
1989
|
+
allowPendingApply = false,
|
|
1990
|
+
): boolean {
|
|
1991
|
+
return this.isSessionRuntimeIdle(state, stored)
|
|
1992
|
+
&& !this.activeSlashCommandExecutions.has(
|
|
1993
|
+
this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
|
|
1994
|
+
)
|
|
1995
|
+
&& (stored.pendingReversion === undefined
|
|
1996
|
+
|| (allowPendingApply && stored.pendingReversion.kind === 'apply'));
|
|
1433
1997
|
}
|
|
1434
1998
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1999
|
+
private slashCommandPromptUnavailableReason(
|
|
2000
|
+
state: IStorageState,
|
|
2001
|
+
stored: IStoredSessionState,
|
|
2002
|
+
byteSize: number,
|
|
2003
|
+
): string | undefined {
|
|
2004
|
+
if (state.lifecycle !== 'active') return 'The session namespace is not accepting work.';
|
|
2005
|
+
if (this.activeSlashCommandExecutions.has(
|
|
2006
|
+
this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
|
|
2007
|
+
)) return 'Session already has an active slash command execution.';
|
|
2008
|
+
if (stored.pendingReversion) return 'Session has a pending reversion operation.';
|
|
2009
|
+
if (stored.session.agent !== undefined) {
|
|
2010
|
+
return 'Subagent sessions can only be prompted through the foreground task tool.';
|
|
2011
|
+
}
|
|
2012
|
+
if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
|
|
2013
|
+
return 'Session has reached its outstanding prompt limit.';
|
|
2014
|
+
}
|
|
2015
|
+
if (stored.outstandingPromptBytes + byteSize > this.promptQueueLimits.maxOutstandingBytesPerSession) {
|
|
2016
|
+
return 'Session has reached its outstanding prompt byte limit.';
|
|
2017
|
+
}
|
|
2018
|
+
if (this.pendingPromptAdmissions >= this.promptQueueLimits.maxPendingAdmissions) {
|
|
2019
|
+
return 'FlexHarness has reached its pending prompt admission limit.';
|
|
2020
|
+
}
|
|
2021
|
+
if (this.pendingPromptAdmissionBytes + byteSize > this.promptQueueLimits.maxPendingAdmissionBytes) {
|
|
2022
|
+
return 'FlexHarness has reached its pending prompt admission byte limit.';
|
|
2023
|
+
}
|
|
2024
|
+
return undefined;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
private slashCommandPromptAvailability(
|
|
2028
|
+
state: IStorageState,
|
|
2029
|
+
stored: IStoredSessionState,
|
|
2030
|
+
byteSize: number,
|
|
2031
|
+
): { available: boolean; unavailableReason?: string } {
|
|
2032
|
+
const unavailableReason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
|
|
2033
|
+
return unavailableReason === undefined
|
|
2034
|
+
? { available: true }
|
|
2035
|
+
: { available: false, unavailableReason };
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
private requireSlashCommandPromptAvailable(
|
|
2039
|
+
state: IStorageState,
|
|
2040
|
+
stored: IStoredSessionState,
|
|
2041
|
+
commandName: string,
|
|
2042
|
+
byteSize: number,
|
|
2043
|
+
): void {
|
|
2044
|
+
const reason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
|
|
2045
|
+
if (reason) throw new FlexHarnessSlashCommandUnavailableError(commandName, reason);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
private promptAdmissionByteSize(
|
|
2049
|
+
prompt: TFlexPrompt,
|
|
2050
|
+
options: IFlexPromptOptions,
|
|
2051
|
+
scheduleKey?: string,
|
|
2052
|
+
debounceMs?: number,
|
|
2053
|
+
): number {
|
|
2054
|
+
return jsonBytes({
|
|
2055
|
+
prompt: normalizeFlexPrompt(prompt),
|
|
2056
|
+
options: cloneSerializable(options),
|
|
2057
|
+
scheduleKey,
|
|
2058
|
+
debounceMs,
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
private isSessionRuntimeIdle(state: IStorageState, stored: IStoredSessionState): boolean {
|
|
2063
|
+
return stored.session.status === 'idle'
|
|
2064
|
+
&& !state.activeRuns.has(stored.session.sessionId)
|
|
2065
|
+
&& stored.outstandingPromptsById.size === 0;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
private slashCommandSessionKey(storageKey: string, sessionId: string): string {
|
|
2069
|
+
return JSON.stringify([storageKey, sessionId]);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
private requireSessionAvailableForSlashCommand(
|
|
2073
|
+
state: IStorageState,
|
|
2074
|
+
stored: IStoredSessionState,
|
|
2075
|
+
commandName: string,
|
|
2076
|
+
): void {
|
|
2077
|
+
if (!this.isSessionAvailableForSlashCommand(state, stored)) {
|
|
2078
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2079
|
+
commandName,
|
|
2080
|
+
slashCommandSessionUnavailableReason,
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
private trackSlashCommandExecution<TResult>(
|
|
2086
|
+
scopeId: string,
|
|
2087
|
+
state: IStorageState,
|
|
2088
|
+
sessionId: string,
|
|
2089
|
+
kind: IActiveSlashCommandExecution['kind'],
|
|
2090
|
+
externalSignal: AbortSignal | undefined,
|
|
2091
|
+
operation: (signal: AbortSignal) => Promise<TResult>,
|
|
2092
|
+
): Promise<TResult> {
|
|
2093
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
|
|
2094
|
+
if (this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
2095
|
+
throw new FlexHarnessSessionBusyError(
|
|
2096
|
+
sessionId,
|
|
2097
|
+
'already has an active slash command execution',
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
const controller = new AbortController();
|
|
2101
|
+
const abortFromExternalSignal = () => {
|
|
2102
|
+
if (!controller.signal.aborted) {
|
|
2103
|
+
controller.abort(this.trustInternalError(
|
|
2104
|
+
new FlexHarnessAbortError('The slash command was aborted.'),
|
|
2105
|
+
));
|
|
2106
|
+
}
|
|
2107
|
+
};
|
|
2108
|
+
externalSignal?.addEventListener('abort', abortFromExternalSignal, { once: true });
|
|
2109
|
+
if (externalSignal?.aborted) abortFromExternalSignal();
|
|
2110
|
+
const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
|
|
2111
|
+
const completion = Promise.resolve().then(() => this.slashCommandActivityContext.run(activityOwner, () => {
|
|
2112
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
2113
|
+
return operation(controller.signal);
|
|
2114
|
+
}));
|
|
2115
|
+
const active: IActiveSlashCommandExecution = {
|
|
2116
|
+
kind,
|
|
2117
|
+
storageKey: state.storageKey,
|
|
2118
|
+
sessionId,
|
|
2119
|
+
controller,
|
|
2120
|
+
completion,
|
|
2121
|
+
};
|
|
2122
|
+
this.activeSlashCommandExecutions.set(sessionKey, active);
|
|
2123
|
+
return completion.finally(() => {
|
|
2124
|
+
externalSignal?.removeEventListener('abort', abortFromExternalSignal);
|
|
2125
|
+
if (this.activeSlashCommandExecutions.get(sessionKey) === active) {
|
|
2126
|
+
this.activeSlashCommandExecutions.delete(sessionKey);
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
private trackSlashCommandListing<TResult>(
|
|
2132
|
+
scopeId: string,
|
|
2133
|
+
state: IStorageState,
|
|
2134
|
+
sessionId: string,
|
|
2135
|
+
operation: (signal: AbortSignal) => Promise<TResult>,
|
|
2136
|
+
): Promise<TResult> {
|
|
2137
|
+
const controller = new AbortController();
|
|
2138
|
+
const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
|
|
2139
|
+
const completion = Promise.resolve().then(() =>
|
|
2140
|
+
this.slashCommandActivityContext.run(activityOwner, () => operation(controller.signal)));
|
|
2141
|
+
const active: IActiveSlashCommandListing = {
|
|
2142
|
+
storageKey: state.storageKey,
|
|
2143
|
+
sessionId,
|
|
2144
|
+
controller,
|
|
2145
|
+
completion,
|
|
2146
|
+
};
|
|
2147
|
+
this.activeSlashCommandListings.add(active);
|
|
2148
|
+
return completion.finally(() => {
|
|
2149
|
+
this.activeSlashCommandListings.delete(active);
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
private async executeSlashCommandHandler(
|
|
2154
|
+
scopeId: string,
|
|
2155
|
+
scope: TScope,
|
|
2156
|
+
state: IStorageState,
|
|
2157
|
+
stored: IStoredSessionState,
|
|
2158
|
+
commandName: string,
|
|
2159
|
+
rawArguments: string,
|
|
2160
|
+
arguments_: readonly string[],
|
|
2161
|
+
handler: IFlexSlashCommandHandlerRegistration<TScope>['handler'],
|
|
2162
|
+
externalSignal?: AbortSignal,
|
|
2163
|
+
): Promise<TFlexSlashCommandExecutionResult> {
|
|
2164
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, stored.session.sessionId);
|
|
2165
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
2166
|
+
if (invocationOwner?.sessionKey === sessionKey) {
|
|
2167
|
+
throw this.trustInternalError(
|
|
2168
|
+
new FlexHarnessSlashCommandReentryError(stored.session.sessionId),
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
if (this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
2172
|
+
throw new FlexHarnessSessionBusyError(
|
|
2173
|
+
stored.session.sessionId,
|
|
2174
|
+
'already has an active slash command execution',
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
const owner = Object.freeze({
|
|
2178
|
+
scopeId,
|
|
2179
|
+
storageKey: state.storageKey,
|
|
2180
|
+
sessionId: stored.session.sessionId,
|
|
2181
|
+
sessionKey,
|
|
2182
|
+
});
|
|
2183
|
+
return this.trackSlashCommandExecution(
|
|
2184
|
+
scopeId,
|
|
2185
|
+
state,
|
|
2186
|
+
stored.session.sessionId,
|
|
2187
|
+
'handler',
|
|
2188
|
+
externalSignal,
|
|
2189
|
+
(signal) => this.slashCommandInvocationContext.run(owner, async () => {
|
|
2190
|
+
const context = Object.freeze({
|
|
2191
|
+
scopeId,
|
|
2192
|
+
scope,
|
|
2193
|
+
storageKey: state.storageKey,
|
|
2194
|
+
sessionId: stored.session.sessionId,
|
|
2195
|
+
rawArguments,
|
|
2196
|
+
arguments: arguments_,
|
|
2197
|
+
signal,
|
|
2198
|
+
});
|
|
2199
|
+
try {
|
|
2200
|
+
await this.commitRevertedBranch(state, stored, scopeId, scope);
|
|
2201
|
+
const result = await handler(context);
|
|
2202
|
+
return Object.freeze({
|
|
2203
|
+
type: 'handler-result' as const,
|
|
2204
|
+
name: commandName,
|
|
2205
|
+
result: normalizeJsonValue(result === undefined ? null : result, this.toolOutputLimits),
|
|
2206
|
+
});
|
|
2207
|
+
} catch (error) {
|
|
2208
|
+
throw this.projectOperationError(
|
|
2209
|
+
error,
|
|
2210
|
+
'slashCommand',
|
|
2211
|
+
scopeId,
|
|
2212
|
+
stored.session.sessionId,
|
|
2213
|
+
`slash-command:${commandName}`,
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
}),
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
public async prompt(
|
|
2221
|
+
scopeId: string,
|
|
2222
|
+
sessionId: string,
|
|
2223
|
+
prompt: TFlexPrompt,
|
|
2224
|
+
options: IFlexPromptOptions = {},
|
|
2225
|
+
): Promise<IFlexPromptResult> {
|
|
2226
|
+
const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
|
|
2227
|
+
return admission.completion;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
public async startPrompt(
|
|
2231
|
+
scopeId: string,
|
|
2232
|
+
sessionId: string,
|
|
2233
|
+
prompt: TFlexPrompt,
|
|
2234
|
+
options: IFlexPromptOptions = {},
|
|
2235
|
+
): Promise<IFlexPromptAdmission> {
|
|
2236
|
+
validatePromptOptions(options, false);
|
|
2237
|
+
const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
|
|
2238
|
+
return queued.started;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
public async enqueuePrompt(
|
|
2242
|
+
scopeId: string,
|
|
2243
|
+
sessionId: string,
|
|
2244
|
+
prompt: TFlexPrompt,
|
|
2245
|
+
options: IFlexPromptOptions = {},
|
|
2246
|
+
): Promise<IFlexPromptQueueAdmission> {
|
|
2247
|
+
validatePromptOptions(options, false);
|
|
2248
|
+
const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
|
|
2249
|
+
return queued.admission;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
public async schedulePrompt(
|
|
2253
|
+
scopeId: string,
|
|
2254
|
+
sessionId: string,
|
|
2255
|
+
scheduleKey: string,
|
|
2256
|
+
prompt: TFlexPrompt,
|
|
2257
|
+
options: IFlexSchedulePromptOptions = {},
|
|
2258
|
+
): Promise<IFlexScheduledPromptAdmission> {
|
|
2259
|
+
validateIdentifier(scheduleKey, 'scheduleKey');
|
|
2260
|
+
requireTransferIdentifier(scheduleKey, 'scheduleKey');
|
|
2261
|
+
validatePromptOptions(options, true);
|
|
2262
|
+
const debounceMs = options.debounceMs ?? 50;
|
|
2263
|
+
if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
|
|
2264
|
+
throw new FlexHarnessValidationError(
|
|
2265
|
+
`debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
const queued = await this.enqueuePromptInternal(
|
|
2269
|
+
scopeId,
|
|
2270
|
+
sessionId,
|
|
2271
|
+
prompt,
|
|
2272
|
+
options,
|
|
2273
|
+
scheduleKey,
|
|
2274
|
+
debounceMs,
|
|
2275
|
+
);
|
|
2276
|
+
const admission = await queued.started;
|
|
2277
|
+
return Object.freeze({ ...admission, scheduleKey });
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
public async getPromptQueueEntry(
|
|
2281
|
+
scopeId: string,
|
|
2282
|
+
sessionId: string,
|
|
2283
|
+
queueId: string,
|
|
2284
|
+
): Promise<IFlexPromptQueueEntry> {
|
|
2285
|
+
validateIdentifier(queueId, 'queueId');
|
|
2286
|
+
requireTransferIdentifier(queueId, 'queueId');
|
|
2287
|
+
const { state } = await this.resolveState(scopeId);
|
|
2288
|
+
this.assertStateAcceptingWork(state);
|
|
2289
|
+
const stored = this.requireSession(state, sessionId);
|
|
2290
|
+
const entry = this.promptQueueEntry(stored, queueId);
|
|
2291
|
+
if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
|
|
2292
|
+
return publicSnapshot(entry);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
public async listPromptQueueEntries(
|
|
2296
|
+
scopeId: string,
|
|
2297
|
+
sessionId: string,
|
|
2298
|
+
): Promise<IFlexPromptQueueEntry[]> {
|
|
2299
|
+
const { state } = await this.resolveState(scopeId);
|
|
2300
|
+
this.assertStateAcceptingWork(state);
|
|
2301
|
+
const stored = this.requireSession(state, sessionId);
|
|
1442
2302
|
return publicSnapshot([
|
|
1443
2303
|
...[...stored.outstandingPromptsById.values()].map((entry) => this.projectPromptQueueEntry(entry)),
|
|
1444
2304
|
...stored.terminalPromptQueueEntries.values(),
|
|
@@ -1618,90 +2478,692 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1618
2478
|
public async compactSession(scopeId: string, sessionId: string): Promise<void> {
|
|
1619
2479
|
const { scope, state } = await this.resolveState(scopeId);
|
|
1620
2480
|
const stored = this.requireSession(state, sessionId);
|
|
2481
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
2482
|
+
if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
|
|
2483
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
2484
|
+
}
|
|
1621
2485
|
this.assertStateAcceptingWork(state);
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
2486
|
+
if (!this.agentSessionPolicy.contextCompactor) {
|
|
2487
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2488
|
+
'compact',
|
|
2489
|
+
'No context compactor is configured.',
|
|
1626
2490
|
);
|
|
1627
|
-
} catch (error) {
|
|
1628
|
-
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'compaction');
|
|
1629
2491
|
}
|
|
2492
|
+
this.requireSessionAvailableForSlashCommand(state, stored, 'compact');
|
|
2493
|
+
await this.trackSlashCommandExecution(
|
|
2494
|
+
scopeId,
|
|
2495
|
+
state,
|
|
2496
|
+
sessionId,
|
|
2497
|
+
'operation',
|
|
2498
|
+
undefined,
|
|
2499
|
+
async (signal) => {
|
|
2500
|
+
await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
|
|
2501
|
+
await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
|
|
2502
|
+
},
|
|
2503
|
+
);
|
|
1630
2504
|
}
|
|
1631
2505
|
|
|
1632
|
-
|
|
2506
|
+
private async compactStoredSession(
|
|
1633
2507
|
scopeId: string,
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
requireTransferIdentifier(compactionEventId, 'compactionEventId');
|
|
1640
|
-
}
|
|
1641
|
-
const { state } = await this.resolveState(scopeId);
|
|
1642
|
-
const stored = this.requireSession(state, sessionId);
|
|
1643
|
-
this.assertStateAcceptingWork(state);
|
|
2508
|
+
scope: TScope,
|
|
2509
|
+
state: IStorageState,
|
|
2510
|
+
stored: IStoredSessionState,
|
|
2511
|
+
signal?: AbortSignal,
|
|
2512
|
+
): Promise<void> {
|
|
1644
2513
|
try {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
2514
|
+
await this.withCompactorContext(
|
|
2515
|
+
this.createCompactorContext(
|
|
2516
|
+
scopeId,
|
|
2517
|
+
scope,
|
|
2518
|
+
state.storageKey,
|
|
2519
|
+
stored.session.sessionId,
|
|
2520
|
+
),
|
|
2521
|
+
() => stored.agentSession.compact(signal === undefined ? {} : { abort: signal }),
|
|
2522
|
+
);
|
|
1653
2523
|
} catch (error) {
|
|
1654
|
-
throw this.projectOperationError(
|
|
2524
|
+
throw this.projectOperationError(
|
|
2525
|
+
error,
|
|
2526
|
+
'agentSession',
|
|
2527
|
+
scopeId,
|
|
2528
|
+
stored.session.sessionId,
|
|
2529
|
+
'compaction',
|
|
2530
|
+
);
|
|
1655
2531
|
}
|
|
1656
2532
|
}
|
|
1657
2533
|
|
|
1658
|
-
public async
|
|
2534
|
+
public async undoSession(
|
|
1659
2535
|
scopeId: string,
|
|
1660
2536
|
sessionId: string,
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
const
|
|
1664
|
-
|
|
1665
|
-
if (!stored.jobs) return [];
|
|
1666
|
-
try {
|
|
1667
|
-
const jobs = await stored.jobs.list();
|
|
1668
|
-
return publicSnapshot(jobs
|
|
1669
|
-
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
|
|
1670
|
-
.slice(0, maxBackgroundExecutions)
|
|
1671
|
-
.map((job) => this.projectBackgroundExecution(job)));
|
|
1672
|
-
} catch (error) {
|
|
1673
|
-
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-list');
|
|
1674
|
-
}
|
|
2537
|
+
signal?: AbortSignal,
|
|
2538
|
+
): Promise<IFlexUndoSessionResult> {
|
|
2539
|
+
const result = await this.changeReversionCursor(scopeId, sessionId, 'undo', signal);
|
|
2540
|
+
return Object.freeze({ revertedRunId: result });
|
|
1675
2541
|
}
|
|
1676
2542
|
|
|
1677
|
-
public async
|
|
2543
|
+
public async redoSession(
|
|
1678
2544
|
scopeId: string,
|
|
1679
2545
|
sessionId: string,
|
|
1680
|
-
|
|
1681
|
-
): Promise<
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
2546
|
+
signal?: AbortSignal,
|
|
2547
|
+
): Promise<IFlexRedoSessionResult> {
|
|
2548
|
+
const result = await this.changeReversionCursor(scopeId, sessionId, 'redo', signal);
|
|
2549
|
+
return Object.freeze({ restoredRunId: result });
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
private reversionUnit(
|
|
2553
|
+
stored: IStoredSessionState,
|
|
2554
|
+
direction: 'undo' | 'redo',
|
|
2555
|
+
): { target: IFlexReversionSegment; segments: IFlexReversionSegment[]; toCursor: number } | undefined {
|
|
2556
|
+
const groups = this.reversionGroups(stored);
|
|
2557
|
+
if (direction === 'undo') {
|
|
2558
|
+
let start = stored.revertCursor - 1;
|
|
2559
|
+
while (start >= 0 && groups[start]?.kind === 'no-change') start--;
|
|
2560
|
+
const candidate = groups[start];
|
|
2561
|
+
if (!candidate || candidate.kind !== 'candidate') return undefined;
|
|
2562
|
+
return {
|
|
2563
|
+
target: candidate.target,
|
|
2564
|
+
segments: groups.slice(start, stored.revertCursor).flatMap((group) => group.segments),
|
|
2565
|
+
toCursor: start,
|
|
2566
|
+
};
|
|
1693
2567
|
}
|
|
2568
|
+
const candidate = groups[stored.revertCursor];
|
|
2569
|
+
if (!candidate || candidate.kind !== 'candidate') return undefined;
|
|
2570
|
+
let end = stored.revertCursor + 1;
|
|
2571
|
+
while (groups[end]?.kind === 'no-change') end++;
|
|
2572
|
+
return {
|
|
2573
|
+
target: candidate.target,
|
|
2574
|
+
segments: groups.slice(stored.revertCursor, end).flatMap((group) => group.segments),
|
|
2575
|
+
toCursor: end,
|
|
2576
|
+
};
|
|
1694
2577
|
}
|
|
1695
2578
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
2579
|
+
private reversionGroups(stored: IStoredSessionState): IReversionGroup[] {
|
|
2580
|
+
const groups: IReversionGroup[] = [];
|
|
2581
|
+
let leading: IFlexReversionSegment[] = [];
|
|
2582
|
+
for (const segment of stored.reversionSegments) {
|
|
2583
|
+
if (segment.status === 'completed') {
|
|
2584
|
+
const segments = groups.length === 0 ? [...leading, segment] : [segment];
|
|
2585
|
+
leading = [];
|
|
2586
|
+
groups.push({
|
|
2587
|
+
target: segment,
|
|
2588
|
+
segments,
|
|
2589
|
+
kind: 'candidate',
|
|
2590
|
+
affectedWorkspaces: [],
|
|
2591
|
+
affectedWorkspacesTruncated: false,
|
|
2592
|
+
});
|
|
2593
|
+
} else if (groups.length > 0) {
|
|
2594
|
+
groups[groups.length - 1].segments.push(segment);
|
|
2595
|
+
} else {
|
|
2596
|
+
leading.push(segment);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
for (const group of groups) {
|
|
2600
|
+
const affected = new Map<string, IFlexAffectedWorkspace>();
|
|
2601
|
+
for (const workspace of group.segments.flatMap((segment) =>
|
|
2602
|
+
segment.affectedWorkspaces ?? [])) {
|
|
2603
|
+
if (affected.has(workspace.id)) continue;
|
|
2604
|
+
if (affected.size < FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
|
|
2605
|
+
affected.set(workspace.id, cloneSerializable(workspace));
|
|
2606
|
+
} else {
|
|
2607
|
+
group.affectedWorkspacesTruncated = true;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
group.affectedWorkspaces = [...affected.values()];
|
|
2611
|
+
if (this.reversionPolicy === 'transcript-optional') continue;
|
|
2612
|
+
if (group.segments.some((segment) =>
|
|
2613
|
+
segment.status === 'capturing'
|
|
2614
|
+
|| segment.disposition === 'pending'
|
|
2615
|
+
|| (segment.protocolVersion === 1 && segment.provenance === 'transcript')
|
|
2616
|
+
|| segment.disposition === 'nonrevertible')) {
|
|
2617
|
+
group.kind = 'barrier';
|
|
2618
|
+
} else if (group.segments.some((segment) => segment.disposition === 'revertible')) {
|
|
2619
|
+
group.kind = 'candidate';
|
|
2620
|
+
} else {
|
|
2621
|
+
group.kind = 'no-change';
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
return groups;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
private async requireReversionIdle(
|
|
2628
|
+
state: IStorageState,
|
|
2629
|
+
stored: IStoredSessionState,
|
|
2630
|
+
sessionId: string,
|
|
2631
|
+
allowPendingApply = false,
|
|
2632
|
+
operationSignal?: AbortSignal,
|
|
2633
|
+
): Promise<void> {
|
|
2634
|
+
const reason = await this.reversionUnavailableReason(
|
|
2635
|
+
state,
|
|
2636
|
+
stored,
|
|
2637
|
+
allowPendingApply,
|
|
2638
|
+
operationSignal,
|
|
2639
|
+
);
|
|
2640
|
+
if (reason) throw new FlexHarnessSessionBusyError(sessionId, reason.toLowerCase().replace(/\.$/u, ''));
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
private async reversionUnavailableReason(
|
|
2644
|
+
state: IStorageState,
|
|
2645
|
+
stored: IStoredSessionState,
|
|
2646
|
+
allowPendingApply = false,
|
|
2647
|
+
operationSignal?: AbortSignal,
|
|
2648
|
+
): Promise<string | undefined> {
|
|
2649
|
+
if (stored.pendingReversion && !(allowPendingApply && stored.pendingReversion.kind === 'apply')) {
|
|
2650
|
+
return 'Session has a pending reversion operation.';
|
|
2651
|
+
}
|
|
2652
|
+
if (!this.isSessionRuntimeIdle(state, stored)) {
|
|
2653
|
+
return slashCommandSessionUnavailableReason;
|
|
2654
|
+
}
|
|
2655
|
+
const activeOperation = this.activeSlashCommandExecutions.get(
|
|
2656
|
+
this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
|
|
2657
|
+
);
|
|
2658
|
+
if (activeOperation && activeOperation.controller.signal !== operationSignal) {
|
|
2659
|
+
return 'Session already has an active slash command execution.';
|
|
2660
|
+
}
|
|
2661
|
+
if ([...state.pendingPermissions.values()].some((pending) =>
|
|
2662
|
+
!pending.settled && pending.request.sessionId === stored.session.sessionId)) {
|
|
2663
|
+
return 'Session has a pending permission.';
|
|
2664
|
+
}
|
|
2665
|
+
if (stored.agentSession.listUncertainToolExecutions().length > 0) {
|
|
2666
|
+
return 'Session has uncertain tool executions.';
|
|
2667
|
+
}
|
|
2668
|
+
if (stored.jobs) {
|
|
2669
|
+
operationSignal?.throwIfAborted();
|
|
2670
|
+
const jobs = Promise.resolve().then(() => stored.jobs!.list());
|
|
2671
|
+
if ((await this.awaitReversionOperation(jobs, operationSignal))
|
|
2672
|
+
.some((job) => job.state === 'running')) {
|
|
2673
|
+
return 'Session has running background jobs.';
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
return undefined;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
private async awaitReversionOperation<TResult>(
|
|
2680
|
+
operation: Promise<TResult>,
|
|
2681
|
+
signal?: AbortSignal,
|
|
2682
|
+
): Promise<TResult> {
|
|
2683
|
+
if (!signal) return operation;
|
|
2684
|
+
void operation.catch(() => undefined);
|
|
2685
|
+
let rejectForAbort!: () => void;
|
|
2686
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
2687
|
+
rejectForAbort = () => reject(
|
|
2688
|
+
signal.reason ?? this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.')),
|
|
2689
|
+
);
|
|
2690
|
+
signal.addEventListener('abort', rejectForAbort, { once: true });
|
|
2691
|
+
if (signal.aborted) rejectForAbort();
|
|
2692
|
+
});
|
|
2693
|
+
try {
|
|
2694
|
+
return await Promise.race([operation, aborted]);
|
|
2695
|
+
} finally {
|
|
2696
|
+
signal.removeEventListener('abort', rejectForAbort);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
private async changeReversionCursor(
|
|
2701
|
+
scopeId: string,
|
|
2702
|
+
sessionId: string,
|
|
2703
|
+
direction: 'undo' | 'redo',
|
|
2704
|
+
signal?: AbortSignal,
|
|
2705
|
+
): Promise<string> {
|
|
2706
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
2707
|
+
return this.changeReversionCursorResolved(
|
|
2708
|
+
scopeId,
|
|
2709
|
+
sessionId,
|
|
2710
|
+
direction,
|
|
2711
|
+
scope.scope,
|
|
2712
|
+
state,
|
|
2713
|
+
signal,
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
private async changeReversionCursorResolved(
|
|
2718
|
+
scopeId: string,
|
|
2719
|
+
sessionId: string,
|
|
2720
|
+
direction: 'undo' | 'redo',
|
|
2721
|
+
scope: TScope,
|
|
2722
|
+
state: IStorageState,
|
|
2723
|
+
signal?: AbortSignal,
|
|
2724
|
+
): Promise<string> {
|
|
2725
|
+
const stored = this.requireSession(state, sessionId);
|
|
2726
|
+
this.assertStateAcceptingWork(state);
|
|
2727
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
2728
|
+
if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
|
|
2729
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
2730
|
+
}
|
|
2731
|
+
return this.trackSlashCommandExecution(
|
|
2732
|
+
scopeId,
|
|
2733
|
+
state,
|
|
2734
|
+
sessionId,
|
|
2735
|
+
'operation',
|
|
2736
|
+
signal,
|
|
2737
|
+
async (operationSignal) => {
|
|
2738
|
+
await stored.projectionQueue;
|
|
2739
|
+
const resumable = stored.pendingReversion?.kind === 'apply'
|
|
2740
|
+
&& stored.pendingReversion.direction === direction;
|
|
2741
|
+
await this.requireReversionIdle(
|
|
2742
|
+
state,
|
|
2743
|
+
stored,
|
|
2744
|
+
sessionId,
|
|
2745
|
+
resumable,
|
|
2746
|
+
operationSignal,
|
|
2747
|
+
);
|
|
2748
|
+
await this.reconcileContextAvailability(state, stored);
|
|
2749
|
+
operationSignal.throwIfAborted();
|
|
2750
|
+
const pending = stored.pendingReversion;
|
|
2751
|
+
if (pending?.kind === 'apply') {
|
|
2752
|
+
if (pending.direction !== direction) {
|
|
2753
|
+
throw new FlexHarnessSessionBusyError(sessionId, 'has a pending opposite reversion operation');
|
|
2754
|
+
}
|
|
2755
|
+
const target = pending.segmentRunIds
|
|
2756
|
+
.map((runId) => stored.reversionSegments.find((segment) => segment.runId === runId))
|
|
2757
|
+
.find((segment) => segment?.status === 'completed');
|
|
2758
|
+
if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
|
|
2759
|
+
await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
|
|
2760
|
+
this.emitEvent(scopeId, sessionId, {
|
|
2761
|
+
type: 'session.history.changed',
|
|
2762
|
+
direction,
|
|
2763
|
+
runId: target.runId,
|
|
2764
|
+
});
|
|
2765
|
+
return target.runId;
|
|
2766
|
+
}
|
|
2767
|
+
const unit = this.reversionUnit(stored, direction);
|
|
2768
|
+
if (!unit) {
|
|
2769
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2770
|
+
direction,
|
|
2771
|
+
direction === 'undo' ? 'No visible turn can be undone.' : 'No hidden turn can be redone.',
|
|
2772
|
+
);
|
|
2773
|
+
}
|
|
2774
|
+
if (!unit.target.contextAvailable) {
|
|
2775
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2776
|
+
direction,
|
|
2777
|
+
'The turn is beyond the context archive horizon.',
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
2780
|
+
if (this.turnReversionProvider && unit.segments.some((segment) =>
|
|
2781
|
+
segment.disposition === 'revertible' && segment.workspaceReference === undefined)) {
|
|
2782
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2783
|
+
direction,
|
|
2784
|
+
'The turn has no available workspace reversion capture.',
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
const operationId = this.reversionId(
|
|
2788
|
+
'apply',
|
|
2789
|
+
state.storageKey,
|
|
2790
|
+
sessionId,
|
|
2791
|
+
JSON.stringify([direction, stored.revertCursor, unit.toCursor, unit.target.runId]),
|
|
2792
|
+
);
|
|
2793
|
+
await this.mutateProjection(state, stored, () => {
|
|
2794
|
+
stored.pendingReversion = {
|
|
2795
|
+
kind: 'apply',
|
|
2796
|
+
operationId,
|
|
2797
|
+
direction,
|
|
2798
|
+
fromCursor: stored.revertCursor,
|
|
2799
|
+
toCursor: unit.toCursor,
|
|
2800
|
+
segmentRunIds: unit.segments.map((segment) => segment.runId),
|
|
2801
|
+
appliedRunIds: [],
|
|
2802
|
+
};
|
|
2803
|
+
}, true);
|
|
2804
|
+
await this.resumePendingApply(
|
|
2805
|
+
state,
|
|
2806
|
+
stored,
|
|
2807
|
+
scopeId,
|
|
2808
|
+
scope,
|
|
2809
|
+
operationSignal,
|
|
2810
|
+
);
|
|
2811
|
+
this.emitEvent(scopeId, sessionId, {
|
|
2812
|
+
type: 'session.history.changed',
|
|
2813
|
+
direction,
|
|
2814
|
+
runId: unit.target.runId,
|
|
2815
|
+
});
|
|
2816
|
+
return unit.target.runId;
|
|
2817
|
+
},
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
private async reconcileContextAvailability(
|
|
2822
|
+
state: IStorageState,
|
|
2823
|
+
stored: IStoredSessionState,
|
|
2824
|
+
): Promise<boolean> {
|
|
2825
|
+
const activeEvents = new Set(stored.agentSession.getEvents().map((event) => event.id));
|
|
2826
|
+
if (!stored.reversionSegments.some((segment) =>
|
|
2827
|
+
segment.contextAvailable
|
|
2828
|
+
&& segment.eventIds.length > 0
|
|
2829
|
+
&& !segment.eventIds.some((eventId) => activeEvents.has(eventId)))) return false;
|
|
2830
|
+
let branchCommitted = false;
|
|
2831
|
+
await this.mutateProjection(state, stored, () => {
|
|
2832
|
+
for (const segment of stored.reversionSegments) {
|
|
2833
|
+
if (
|
|
2834
|
+
segment.contextAvailable
|
|
2835
|
+
&& segment.eventIds.length > 0
|
|
2836
|
+
&& !segment.eventIds.some((eventId) => activeEvents.has(eventId))
|
|
2837
|
+
) segment.contextAvailable = false;
|
|
2838
|
+
}
|
|
2839
|
+
branchCommitted = this.commitArchivedHiddenBranchState(stored);
|
|
2840
|
+
this.pruneReversionState(stored, false, true);
|
|
2841
|
+
}, true);
|
|
2842
|
+
const context = stored.compactorContext as IFlexAgentContextInvocation<TScope> | undefined;
|
|
2843
|
+
if (context) {
|
|
2844
|
+
await this.drainReversionReleases(state, stored, context.scopeId, context.scope, false);
|
|
2845
|
+
}
|
|
2846
|
+
return branchCommitted;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
private async resumePendingApply(
|
|
2850
|
+
state: IStorageState,
|
|
2851
|
+
stored: IStoredSessionState,
|
|
2852
|
+
scopeId: string,
|
|
2853
|
+
scope: TScope,
|
|
2854
|
+
signal?: AbortSignal,
|
|
2855
|
+
): Promise<void> {
|
|
2856
|
+
const pending = stored.pendingReversion;
|
|
2857
|
+
if (pending?.kind !== 'apply') return;
|
|
2858
|
+
const segmentMap = new Map(stored.reversionSegments.map((segment) => [segment.runId, segment]));
|
|
2859
|
+
if (
|
|
2860
|
+
pending.segmentRunIds.some((runId) =>
|
|
2861
|
+
segmentMap.get(runId)?.disposition === 'revertible')
|
|
2862
|
+
&& !this.turnReversionProvider
|
|
2863
|
+
) {
|
|
2864
|
+
throw new FlexHarnessValidationError(
|
|
2865
|
+
'Pending workspace reversion recovery requires its turn reversion provider.',
|
|
2866
|
+
);
|
|
2867
|
+
}
|
|
2868
|
+
const orderedRunIds = pending.direction === 'undo'
|
|
2869
|
+
? [...pending.segmentRunIds].reverse()
|
|
2870
|
+
: [...pending.segmentRunIds];
|
|
2871
|
+
let workspaceProgress = pending.appliedRunIds.some((runId) =>
|
|
2872
|
+
segmentMap.get(runId)?.disposition === 'revertible');
|
|
2873
|
+
for (const runId of orderedRunIds) {
|
|
2874
|
+
if (pending.appliedRunIds.includes(runId)) continue;
|
|
2875
|
+
if (signal?.aborted && !workspaceProgress) {
|
|
2876
|
+
await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
|
|
2877
|
+
throw signal.reason;
|
|
2878
|
+
}
|
|
2879
|
+
const segment = segmentMap.get(runId);
|
|
2880
|
+
if (!segment) throw new FlexHarnessValidationError('Pending reversion references a missing segment.');
|
|
2881
|
+
if (segment.disposition === 'revertible') {
|
|
2882
|
+
if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
|
|
2883
|
+
throw new FlexHarnessValidationError('Pending workspace reversion segment is incomplete.');
|
|
2884
|
+
}
|
|
2885
|
+
if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
|
|
2886
|
+
throw new FlexHarnessValidationError(
|
|
2887
|
+
`Pending workspace reversion requires protocolVersion ${segment.protocolVersion}.`,
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2890
|
+
const captureId = segment.captureId;
|
|
2891
|
+
const reference = segment.workspaceReference;
|
|
2892
|
+
const childOperationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
|
|
2893
|
+
const createContext = (contextSignal: AbortSignal) => Object.freeze({
|
|
2894
|
+
scopeId,
|
|
2895
|
+
scope,
|
|
2896
|
+
storageKey: state.storageKey,
|
|
2897
|
+
sessionId: stored.session.sessionId,
|
|
2898
|
+
runId,
|
|
2899
|
+
captureId,
|
|
2900
|
+
reference: cloneSerializable(reference),
|
|
2901
|
+
operationId: childOperationId,
|
|
2902
|
+
direction: pending.direction,
|
|
2903
|
+
signal: contextSignal,
|
|
2904
|
+
});
|
|
2905
|
+
let inspection;
|
|
2906
|
+
try {
|
|
2907
|
+
inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
|
|
2908
|
+
this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
|
|
2909
|
+
} catch (inspectionError) {
|
|
2910
|
+
state.lifecycle = 'fenced';
|
|
2911
|
+
throw this.projectOperationError(
|
|
2912
|
+
inspectionError,
|
|
2913
|
+
'turnReversion',
|
|
2914
|
+
scopeId,
|
|
2915
|
+
stored.session.sessionId,
|
|
2916
|
+
childOperationId,
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
if (inspection.status === 'unknown') {
|
|
2920
|
+
state.lifecycle = 'fenced';
|
|
2921
|
+
throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
|
|
2922
|
+
}
|
|
2923
|
+
if (inspection.status === 'not-applied') {
|
|
2924
|
+
try {
|
|
2925
|
+
if (signal && !workspaceProgress) {
|
|
2926
|
+
await this.turnReversionProvider.apply(createContext(signal));
|
|
2927
|
+
} else {
|
|
2928
|
+
await this.withReversionMaintenanceSignal((maintenanceSignal) =>
|
|
2929
|
+
this.turnReversionProvider!.apply(createContext(maintenanceSignal)));
|
|
2930
|
+
}
|
|
2931
|
+
} catch (error) {
|
|
2932
|
+
try {
|
|
2933
|
+
inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
|
|
2934
|
+
this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
|
|
2935
|
+
} catch (inspectionError) {
|
|
2936
|
+
state.lifecycle = 'fenced';
|
|
2937
|
+
throw this.projectOperationError(
|
|
2938
|
+
inspectionError,
|
|
2939
|
+
'turnReversion',
|
|
2940
|
+
scopeId,
|
|
2941
|
+
stored.session.sessionId,
|
|
2942
|
+
childOperationId,
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
if (inspection.status !== 'applied') {
|
|
2946
|
+
if (inspection.status === 'unknown') state.lifecycle = 'fenced';
|
|
2947
|
+
else await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
|
|
2948
|
+
throw this.projectOperationError(
|
|
2949
|
+
error,
|
|
2950
|
+
'turnReversion',
|
|
2951
|
+
scopeId,
|
|
2952
|
+
stored.session.sessionId,
|
|
2953
|
+
childOperationId,
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
await this.mutateProjection(state, stored, () => {
|
|
2960
|
+
const current = stored.pendingReversion;
|
|
2961
|
+
if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
|
|
2962
|
+
throw new FlexHarnessValidationError('Pending reversion changed while applying.');
|
|
2963
|
+
}
|
|
2964
|
+
if (!current.appliedRunIds.includes(runId)) current.appliedRunIds.push(runId);
|
|
2965
|
+
}, true);
|
|
2966
|
+
if (segment.disposition === 'revertible') workspaceProgress = true;
|
|
2967
|
+
}
|
|
2968
|
+
await this.mutateProjection(state, stored, () => {
|
|
2969
|
+
const current = stored.pendingReversion;
|
|
2970
|
+
if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
|
|
2971
|
+
throw new FlexHarnessValidationError('Pending reversion changed before cursor commit.');
|
|
2972
|
+
}
|
|
2973
|
+
stored.revertCursor = current.toCursor;
|
|
2974
|
+
delete stored.pendingReversion;
|
|
2975
|
+
}, true);
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
private async persistKnownNotApplied(
|
|
2979
|
+
state: IStorageState,
|
|
2980
|
+
stored: IStoredSessionState,
|
|
2981
|
+
operationId: string,
|
|
2982
|
+
runId: string,
|
|
2983
|
+
): Promise<void> {
|
|
2984
|
+
await this.mutateProjection(state, stored, () => {
|
|
2985
|
+
const current = stored.pendingReversion;
|
|
2986
|
+
if (current?.kind !== 'apply' || current.operationId !== operationId) {
|
|
2987
|
+
throw new FlexHarnessValidationError('Pending reversion changed while recording failed apply.');
|
|
2988
|
+
}
|
|
2989
|
+
current.appliedRunIds = current.appliedRunIds.filter((entry) => entry !== runId);
|
|
2990
|
+
const hasWorkspaceProgress = current.appliedRunIds.some((appliedRunId) =>
|
|
2991
|
+
stored.reversionSegments.some((segment) =>
|
|
2992
|
+
segment.runId === appliedRunId && segment.disposition === 'revertible'));
|
|
2993
|
+
if (!hasWorkspaceProgress) {
|
|
2994
|
+
stored.revertCursor = current.fromCursor;
|
|
2995
|
+
delete stored.pendingReversion;
|
|
2996
|
+
}
|
|
2997
|
+
}, true);
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
private async recoverPendingCapture(
|
|
3001
|
+
state: IStorageState,
|
|
3002
|
+
stored: IStoredSessionState,
|
|
3003
|
+
scopeId: string,
|
|
3004
|
+
scope: TScope,
|
|
3005
|
+
outcomes: Map<string, TCanonicalOutcome>,
|
|
3006
|
+
): Promise<void> {
|
|
3007
|
+
const pending = stored.pendingReversion;
|
|
3008
|
+
if (pending?.kind !== 'capture' || !this.turnReversionProvider) return;
|
|
3009
|
+
const segment = stored.reversionSegments.find((entry) => entry.runId === pending.runId);
|
|
3010
|
+
if (!segment) throw new FlexHarnessValidationError('Pending capture references a missing segment.');
|
|
3011
|
+
if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
|
|
3012
|
+
throw new FlexHarnessValidationError(
|
|
3013
|
+
`Pending capture recovery requires protocolVersion ${pending.protocolVersion}.`,
|
|
3014
|
+
);
|
|
3015
|
+
}
|
|
3016
|
+
const outcome = outcomes.get(pending.runId);
|
|
3017
|
+
if (pending.state === 'prepared') {
|
|
3018
|
+
await this.mutateProjection(state, stored, () => {
|
|
3019
|
+
const current = stored.pendingReversion;
|
|
3020
|
+
if (current?.kind !== 'capture' || current.captureId !== pending.captureId) {
|
|
3021
|
+
throw new FlexHarnessValidationError('Pending capture changed before finalization.');
|
|
3022
|
+
}
|
|
3023
|
+
current.state = 'finalizing';
|
|
3024
|
+
}, true);
|
|
3025
|
+
}
|
|
3026
|
+
const finalizedOutcome = await this.resolveReversionCaptureOutcome(
|
|
3027
|
+
state,
|
|
3028
|
+
scopeId,
|
|
3029
|
+
scope,
|
|
3030
|
+
stored.session.sessionId,
|
|
3031
|
+
pending.runId,
|
|
3032
|
+
pending.captureId,
|
|
3033
|
+
pending.state === 'prepared' ? 'finalizing' : pending.state,
|
|
3034
|
+
);
|
|
3035
|
+
if (finalizedOutcome === undefined) {
|
|
3036
|
+
await this.mutateProjection(state, stored, () => {
|
|
3037
|
+
stored.reversionSegments = stored.reversionSegments.filter(
|
|
3038
|
+
(entry) => entry.runId !== pending.runId,
|
|
3039
|
+
);
|
|
3040
|
+
delete stored.pendingReversion;
|
|
3041
|
+
}, true);
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
await this.mutateProjection(state, stored, () => {
|
|
3045
|
+
segment.status = outcome === 'accepted'
|
|
3046
|
+
? 'completed'
|
|
3047
|
+
: outcome === 'rejected'
|
|
3048
|
+
? 'failed'
|
|
3049
|
+
: 'cancelled';
|
|
3050
|
+
this.applyFinalizedReversionOutcome(stored, segment, finalizedOutcome);
|
|
3051
|
+
segment.eventIds = stored.agentSession.getEvents()
|
|
3052
|
+
.filter((event) => event.generationId === pending.runId)
|
|
3053
|
+
.map((event) => event.id);
|
|
3054
|
+
if (segment.status === 'completed') stored.revertCursor++;
|
|
3055
|
+
if (outcome === undefined && !stored.excludedRunIds.includes(pending.runId)) {
|
|
3056
|
+
stored.excludedRunIds.push(pending.runId);
|
|
3057
|
+
}
|
|
3058
|
+
delete stored.pendingReversion;
|
|
3059
|
+
this.pruneReversionState(stored, false, true);
|
|
3060
|
+
}, true);
|
|
3061
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
public async archiveSessionEvents(
|
|
3065
|
+
scopeId: string,
|
|
3066
|
+
sessionId: string,
|
|
3067
|
+
compactionEventId?: string,
|
|
3068
|
+
): Promise<IFlexEventArchiveMetadata | undefined> {
|
|
3069
|
+
if (compactionEventId !== undefined) {
|
|
3070
|
+
validateIdentifier(compactionEventId, 'compactionEventId');
|
|
3071
|
+
requireTransferIdentifier(compactionEventId, 'compactionEventId');
|
|
3072
|
+
}
|
|
3073
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
3074
|
+
const stored = this.requireSession(state, sessionId);
|
|
3075
|
+
this.assertStateAcceptingWork(state);
|
|
3076
|
+
this.requireSessionAvailableForSlashCommand(state, stored, 'archive');
|
|
3077
|
+
const compaction = compactionEventId === undefined
|
|
3078
|
+
? [...stored.agentSession.getEvents()].reverse().find((event) =>
|
|
3079
|
+
event.type === 'context-compaction')
|
|
3080
|
+
: stored.agentSession.getEvents().find((event) =>
|
|
3081
|
+
event.type === 'context-compaction' && event.id === compactionEventId);
|
|
3082
|
+
if (!compaction || compaction.type !== 'context-compaction') return undefined;
|
|
3083
|
+
let archive;
|
|
3084
|
+
try {
|
|
3085
|
+
archive = await stored.agentSession.archiveCompactedEvents(compaction.id);
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'archive');
|
|
3088
|
+
}
|
|
3089
|
+
if (!archive) return undefined;
|
|
3090
|
+
let branchCommitted = false;
|
|
3091
|
+
try {
|
|
3092
|
+
const archivedIds = new Set(archive.events.map((event) => event.id));
|
|
3093
|
+
await this.mutateProjection(state, stored, () => {
|
|
3094
|
+
branchCommitted = this.commitRevertedBranchState(stored);
|
|
3095
|
+
for (const segment of stored.reversionSegments) {
|
|
3096
|
+
if (segment.eventIds.some((eventId) => archivedIds.has(eventId))) {
|
|
3097
|
+
segment.contextAvailable = false;
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
this.pruneReversionState(stored);
|
|
3101
|
+
}, true);
|
|
3102
|
+
} catch (error) {
|
|
3103
|
+
throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'archive-horizon');
|
|
3104
|
+
}
|
|
3105
|
+
if (branchCommitted) {
|
|
3106
|
+
this.emitEvent(scopeId, sessionId, {
|
|
3107
|
+
type: 'session.history.changed',
|
|
3108
|
+
direction: 'branch',
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
await this.drainReversionReleases(state, stored, scopeId, scope.scope, false);
|
|
3112
|
+
return publicSnapshot({
|
|
3113
|
+
archiveId: this.boundedIdentifier(archive.archiveId, 'archiveId'),
|
|
3114
|
+
sessionId: this.boundedIdentifier(archive.sessionId, 'sessionId'),
|
|
3115
|
+
createdAt: new Date(archive.createdAt).toISOString(),
|
|
3116
|
+
eventCount: archive.events.length,
|
|
3117
|
+
});
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
public async listBackgroundExecutions(
|
|
3121
|
+
scopeId: string,
|
|
3122
|
+
sessionId: string,
|
|
3123
|
+
): Promise<IFlexBackgroundExecution[]> {
|
|
3124
|
+
const { state } = await this.resolveState(scopeId);
|
|
3125
|
+
const stored = this.requireSession(state, sessionId);
|
|
3126
|
+
this.assertStateAcceptingWork(state);
|
|
3127
|
+
if (!stored.jobs) return [];
|
|
3128
|
+
try {
|
|
3129
|
+
const jobs = await stored.jobs.list();
|
|
3130
|
+
return publicSnapshot(jobs
|
|
3131
|
+
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
|
|
3132
|
+
.slice(0, maxBackgroundExecutions)
|
|
3133
|
+
.map((job) => this.projectBackgroundExecution(job)));
|
|
3134
|
+
} catch (error) {
|
|
3135
|
+
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-list');
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
public async getBackgroundExecution(
|
|
3140
|
+
scopeId: string,
|
|
3141
|
+
sessionId: string,
|
|
3142
|
+
executionId: string,
|
|
3143
|
+
): Promise<IFlexBackgroundExecution> {
|
|
3144
|
+
validateIdentifier(executionId, 'executionId');
|
|
3145
|
+
requireTransferIdentifier(executionId, 'executionId');
|
|
3146
|
+
const { state } = await this.resolveState(scopeId);
|
|
3147
|
+
const stored = this.requireSession(state, sessionId);
|
|
3148
|
+
this.assertStateAcceptingWork(state);
|
|
3149
|
+
try {
|
|
3150
|
+
return publicSnapshot(this.projectBackgroundExecution(
|
|
3151
|
+
await stored.agentSession.getBackgroundExecution(executionId),
|
|
3152
|
+
));
|
|
3153
|
+
} catch (error) {
|
|
3154
|
+
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-get');
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
public async abortBackgroundExecution(
|
|
3159
|
+
scopeId: string,
|
|
3160
|
+
sessionId: string,
|
|
3161
|
+
executionId: string,
|
|
3162
|
+
): Promise<void> {
|
|
3163
|
+
validateIdentifier(executionId, 'executionId');
|
|
3164
|
+
requireTransferIdentifier(executionId, 'executionId');
|
|
3165
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
3166
|
+
const stored = this.requireSession(state, sessionId);
|
|
1705
3167
|
this.assertStateAcceptingWork(state);
|
|
1706
3168
|
try {
|
|
1707
3169
|
await this.withCompactorContext(
|
|
@@ -1725,6 +3187,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1725
3187
|
}
|
|
1726
3188
|
|
|
1727
3189
|
public retireScope(scopeId: string): Promise<void> {
|
|
3190
|
+
this.assertNoAmbientSlashCommandTeardown({ scopeId });
|
|
1728
3191
|
this.assertOpen();
|
|
1729
3192
|
validateIdentifier(scopeId, 'scopeId');
|
|
1730
3193
|
const existing = this.scopeRetirements.get(scopeId);
|
|
@@ -1743,6 +3206,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1743
3206
|
}
|
|
1744
3207
|
|
|
1745
3208
|
public async dispose(): Promise<void> {
|
|
3209
|
+
this.assertNoAmbientSlashCommandTeardown();
|
|
1746
3210
|
if (this.disposePromise) return this.disposePromise;
|
|
1747
3211
|
this.closed = true;
|
|
1748
3212
|
let disposal!: Promise<void>;
|
|
@@ -1763,16 +3227,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1763
3227
|
debounceMs?: number,
|
|
1764
3228
|
subagentAdmission = false,
|
|
1765
3229
|
admissionSignal?: AbortSignal,
|
|
1766
|
-
|
|
3230
|
+
slashCommandAdmission = false,
|
|
3231
|
+
): Promise<{
|
|
3232
|
+
admission: IFlexPromptQueueAdmission;
|
|
3233
|
+
started: Promise<IFlexPromptAdmission>;
|
|
3234
|
+
queued?: IQueuedPrompt;
|
|
3235
|
+
}> {
|
|
1767
3236
|
this.assertOpen();
|
|
1768
3237
|
const normalizedPrompt = normalizeFlexPrompt(prompt);
|
|
1769
3238
|
const normalizedOptions = cloneSerializable(options);
|
|
1770
|
-
const byteSize =
|
|
1771
|
-
prompt: normalizedPrompt,
|
|
1772
|
-
options: normalizedOptions,
|
|
1773
|
-
scheduleKey,
|
|
1774
|
-
debounceMs,
|
|
1775
|
-
});
|
|
3239
|
+
const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
|
|
1776
3240
|
const releasePendingAdmission = this.reservePendingPromptAdmission(byteSize);
|
|
1777
3241
|
let resolveSettled!: () => void;
|
|
1778
3242
|
const pendingOwner: IPendingPromptAdmission = {
|
|
@@ -1807,6 +3271,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1807
3271
|
}
|
|
1808
3272
|
this.assertStateAcceptingWork(state);
|
|
1809
3273
|
const stored = this.requireSession(state, sessionId);
|
|
3274
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
|
|
3275
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
3276
|
+
if (invocationOwner?.sessionKey === sessionKey) {
|
|
3277
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
3278
|
+
}
|
|
3279
|
+
if (!slashCommandAdmission && this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
3280
|
+
throw new FlexHarnessSessionBusyError(
|
|
3281
|
+
sessionId,
|
|
3282
|
+
'already has an active slash command execution',
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
if (stored.pendingReversion) {
|
|
3286
|
+
throw new FlexHarnessSessionBusyError(sessionId, 'has a pending reversion operation');
|
|
3287
|
+
}
|
|
1810
3288
|
if (stored.session.agent !== undefined && !subagentAdmission) {
|
|
1811
3289
|
throw new FlexHarnessValidationError(
|
|
1812
3290
|
'Subagent sessions can only be prompted through the foreground task tool.',
|
|
@@ -1875,6 +3353,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1875
3353
|
return {
|
|
1876
3354
|
admission: Object.freeze({ queueId: queued.queueId, completion }),
|
|
1877
3355
|
started,
|
|
3356
|
+
queued,
|
|
1878
3357
|
};
|
|
1879
3358
|
} finally {
|
|
1880
3359
|
for (const signal of admissionSignals) {
|
|
@@ -2003,6 +3482,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2003
3482
|
const options = queued.options!;
|
|
2004
3483
|
let projectionReserved = false;
|
|
2005
3484
|
try {
|
|
3485
|
+
await this.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
|
|
2006
3486
|
run.transaction = await this.withRunCompactorContext(
|
|
2007
3487
|
run,
|
|
2008
3488
|
() => run.stored.agentSession.beginGeneration(
|
|
@@ -2016,6 +3496,34 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2016
3496
|
const reservation = this.createReservation(run, prompt);
|
|
2017
3497
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
2018
3498
|
run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
|
|
3499
|
+
if (run.stored.session.agent === undefined) {
|
|
3500
|
+
this.pruneReversionState(run.stored, true);
|
|
3501
|
+
const captureId = this.turnReversionProvider
|
|
3502
|
+
? this.reversionId('capture', run.state.storageKey, run.sessionId, run.runId)
|
|
3503
|
+
: undefined;
|
|
3504
|
+
const protocolVersion = this.reversionProviderProtocolVersion() ?? 1;
|
|
3505
|
+
run.stored.reversionSegments.push({
|
|
3506
|
+
runId: run.runId,
|
|
3507
|
+
userMessageId: reservation.userMessage.messageId,
|
|
3508
|
+
status: 'capturing',
|
|
3509
|
+
contextAvailable: true,
|
|
3510
|
+
eventIds: [],
|
|
3511
|
+
workspaceCaptured: captureId !== undefined,
|
|
3512
|
+
protocolVersion,
|
|
3513
|
+
provenance: captureId === undefined ? 'transcript' : 'workspace',
|
|
3514
|
+
...(captureId === undefined ? {} : { disposition: 'pending' }),
|
|
3515
|
+
...(captureId === undefined ? {} : { captureId }),
|
|
3516
|
+
});
|
|
3517
|
+
if (captureId) {
|
|
3518
|
+
run.stored.pendingReversion = {
|
|
3519
|
+
kind: 'capture',
|
|
3520
|
+
runId: run.runId,
|
|
3521
|
+
captureId,
|
|
3522
|
+
state: 'preparing',
|
|
3523
|
+
protocolVersion,
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
2019
3527
|
});
|
|
2020
3528
|
projectionReserved = true;
|
|
2021
3529
|
run.reservedUserMessage = publicSnapshot(reservation.userMessage);
|
|
@@ -2236,6 +3744,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2236
3744
|
if (run.controller.signal.aborted) throw run.controller.signal.reason;
|
|
2237
3745
|
const result = normalizeRunResult(rawResult);
|
|
2238
3746
|
if (!run.modelResolution) throw new FlexHarnessValidationError('Generation completed without a resolved model.');
|
|
3747
|
+
await this.finalizeRunReversion(run, 'completed');
|
|
2239
3748
|
const terminal = this.buildTerminal(run, 'completed', result);
|
|
2240
3749
|
try {
|
|
2241
3750
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
@@ -2266,9 +3775,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2266
3775
|
}
|
|
2267
3776
|
this.emitTerminalProjection(run, terminal);
|
|
2268
3777
|
this.emitFinalRunEvent(run, terminal.assistantMessage, undefined, false);
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
3778
|
+
await this.drainReversionReleases(
|
|
3779
|
+
run.state,
|
|
3780
|
+
run.stored,
|
|
3781
|
+
run.scopeId,
|
|
3782
|
+
run.scope as TScope,
|
|
3783
|
+
false,
|
|
3784
|
+
);
|
|
3785
|
+
return {
|
|
3786
|
+
runId: run.runId,
|
|
3787
|
+
sessionId: run.sessionId,
|
|
2272
3788
|
userMessage: publicSnapshot(terminal.userMessage),
|
|
2273
3789
|
assistantMessage: publicSnapshot(terminal.assistantMessage),
|
|
2274
3790
|
model: publicSnapshot(terminal.model!),
|
|
@@ -2306,6 +3822,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2306
3822
|
? run.ownerCancellation!
|
|
2307
3823
|
: this.projectExternalError(run, error, generated ? 'persistence' : 'agentSession');
|
|
2308
3824
|
const errors: unknown[] = [safeError];
|
|
3825
|
+
try {
|
|
3826
|
+
await this.finalizeRunReversion(run, cancelled ? 'cancelled' : 'failed');
|
|
3827
|
+
} catch (reversionError) {
|
|
3828
|
+
errors.push(reversionError);
|
|
3829
|
+
const combined = combineErrors(errors);
|
|
3830
|
+
await this.fenceNamespace(run.state, run, combined);
|
|
3831
|
+
throw combined;
|
|
3832
|
+
}
|
|
2309
3833
|
if (generated && !this.isTombstoned(run)) {
|
|
2310
3834
|
const failedStage = this.buildTerminal(run, cancelled ? 'cancelled' : 'failed', undefined, safeError);
|
|
2311
3835
|
try {
|
|
@@ -2360,6 +3884,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2360
3884
|
this.emitTerminalProjection(run, terminal);
|
|
2361
3885
|
this.emitFinalRunEvent(run, terminal.assistantMessage, terminalError, publicStatus === 'cancelled');
|
|
2362
3886
|
}
|
|
3887
|
+
await this.drainReversionReleases(
|
|
3888
|
+
run.state,
|
|
3889
|
+
run.stored,
|
|
3890
|
+
run.scopeId,
|
|
3891
|
+
run.scope as TScope,
|
|
3892
|
+
false,
|
|
3893
|
+
);
|
|
2363
3894
|
throw errors.length > 1 ? combineErrors(errors) : terminalError;
|
|
2364
3895
|
}
|
|
2365
3896
|
|
|
@@ -2369,6 +3900,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2369
3900
|
signal: AbortSignal,
|
|
2370
3901
|
): Promise<plugins.IAgentGenerationLease> {
|
|
2371
3902
|
signal.throwIfAborted();
|
|
3903
|
+
await this.prepareRunReversion(run, signal);
|
|
2372
3904
|
run.phase = 'running';
|
|
2373
3905
|
const queued = run.stored.outstandingPromptsById.get(run.queueId);
|
|
2374
3906
|
if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
|
|
@@ -3036,39 +4568,1047 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3036
4568
|
: 'Tool execution ended without a terminal event.';
|
|
3037
4569
|
}
|
|
3038
4570
|
}
|
|
3039
|
-
userMessage.status = status;
|
|
3040
|
-
userMessage.completedAt = timestamp;
|
|
3041
|
-
assistantMessage.status = status;
|
|
3042
|
-
assistantMessage.completedAt = timestamp;
|
|
3043
|
-
if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
|
|
3044
|
-
if (status === 'completed' && result) {
|
|
3045
|
-
if (!assistantMessage.parts.some((part) => part.type === 'text')) {
|
|
3046
|
-
assistantMessage.parts.push({
|
|
3047
|
-
partId: plugins.crypto.randomUUID(),
|
|
3048
|
-
type: 'text',
|
|
3049
|
-
text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
|
|
4571
|
+
userMessage.status = status;
|
|
4572
|
+
userMessage.completedAt = timestamp;
|
|
4573
|
+
assistantMessage.status = status;
|
|
4574
|
+
assistantMessage.completedAt = timestamp;
|
|
4575
|
+
if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
|
|
4576
|
+
if (status === 'completed' && result) {
|
|
4577
|
+
if (!assistantMessage.parts.some((part) => part.type === 'text')) {
|
|
4578
|
+
assistantMessage.parts.push({
|
|
4579
|
+
partId: plugins.crypto.randomUUID(),
|
|
4580
|
+
type: 'text',
|
|
4581
|
+
text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
|
|
4582
|
+
});
|
|
4583
|
+
}
|
|
4584
|
+
assistantMessage.usage = cloneSerializable(result.usage);
|
|
4585
|
+
} else {
|
|
4586
|
+
const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
|
|
4587
|
+
userMessage.error = message;
|
|
4588
|
+
assistantMessage.error = message;
|
|
4589
|
+
}
|
|
4590
|
+
return {
|
|
4591
|
+
runId: run.runId,
|
|
4592
|
+
status,
|
|
4593
|
+
userMessage,
|
|
4594
|
+
assistantMessage,
|
|
4595
|
+
...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
|
|
4596
|
+
...(status === 'completed' && result
|
|
4597
|
+
? {
|
|
4598
|
+
usage: cloneSerializable(result.usage),
|
|
4599
|
+
finishReason: result.finishReason,
|
|
4600
|
+
steps: result.steps,
|
|
4601
|
+
}
|
|
4602
|
+
: {}),
|
|
4603
|
+
};
|
|
4604
|
+
}
|
|
4605
|
+
|
|
4606
|
+
private reversionId(
|
|
4607
|
+
kind: 'capture' | 'apply',
|
|
4608
|
+
storageKey: string,
|
|
4609
|
+
sessionId: string,
|
|
4610
|
+
value: string,
|
|
4611
|
+
): string {
|
|
4612
|
+
return `${kind}_${sha256Hex(JSON.stringify([storageKey, sessionId, value]))}`;
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
private reversionProviderProtocolVersion(): 1 | 2 | undefined {
|
|
4616
|
+
if (!this.turnReversionProvider) return undefined;
|
|
4617
|
+
return 'protocolVersion' in this.turnReversionProvider ? 2 : 1;
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4620
|
+
private reversionCaptureContext(
|
|
4621
|
+
run: IActiveRun,
|
|
4622
|
+
captureId: string,
|
|
4623
|
+
signal: AbortSignal,
|
|
4624
|
+
) {
|
|
4625
|
+
return Object.freeze({
|
|
4626
|
+
scopeId: run.scopeId,
|
|
4627
|
+
scope: run.scope as TScope,
|
|
4628
|
+
storageKey: run.state.storageKey,
|
|
4629
|
+
sessionId: run.sessionId,
|
|
4630
|
+
runId: run.runId,
|
|
4631
|
+
captureId,
|
|
4632
|
+
signal,
|
|
4633
|
+
});
|
|
4634
|
+
}
|
|
4635
|
+
|
|
4636
|
+
private async withReversionMaintenanceSignal<TResult>(
|
|
4637
|
+
operation: (signal: AbortSignal) => Promise<TResult> | TResult,
|
|
4638
|
+
): Promise<TResult> {
|
|
4639
|
+
const controller = new AbortController();
|
|
4640
|
+
const timeoutMs = this.agentSessionPolicy.generationLeaseCleanupTimeoutMs ?? 30_000;
|
|
4641
|
+
const timeoutError = new Error(`Turn reversion maintenance timed out after ${timeoutMs}ms.`);
|
|
4642
|
+
let rejectTimeout!: (error: Error) => void;
|
|
4643
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
4644
|
+
rejectTimeout = reject;
|
|
4645
|
+
});
|
|
4646
|
+
const timer = setTimeout(() => {
|
|
4647
|
+
controller.abort(timeoutError);
|
|
4648
|
+
rejectTimeout(timeoutError);
|
|
4649
|
+
}, timeoutMs);
|
|
4650
|
+
const completion = Promise.resolve().then(() => operation(controller.signal));
|
|
4651
|
+
void completion.catch(() => undefined);
|
|
4652
|
+
try {
|
|
4653
|
+
return await Promise.race([completion, timeout]);
|
|
4654
|
+
} finally {
|
|
4655
|
+
clearTimeout(timer);
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
|
|
4659
|
+
private inspectReversionCapture(
|
|
4660
|
+
scopeId: string,
|
|
4661
|
+
scope: TScope,
|
|
4662
|
+
storageKey: string,
|
|
4663
|
+
sessionId: string,
|
|
4664
|
+
runId: string,
|
|
4665
|
+
captureId: string,
|
|
4666
|
+
): Promise<TNormalizedCaptureInspection> {
|
|
4667
|
+
return this.withReversionMaintenanceSignal(async (signal) => {
|
|
4668
|
+
const provider = this.turnReversionProvider!;
|
|
4669
|
+
const inspection = await provider.inspectCapture(Object.freeze({
|
|
4670
|
+
scopeId,
|
|
4671
|
+
scope,
|
|
4672
|
+
storageKey,
|
|
4673
|
+
sessionId,
|
|
4674
|
+
runId,
|
|
4675
|
+
captureId,
|
|
4676
|
+
signal,
|
|
4677
|
+
}));
|
|
4678
|
+
if (inspection.status !== 'finalized') return { status: inspection.status };
|
|
4679
|
+
if (this.reversionProviderProtocolVersion() === 2) {
|
|
4680
|
+
if (!('outcome' in inspection)) {
|
|
4681
|
+
throw new FlexHarnessValidationError('Protocol-2 finalized inspection has no outcome.');
|
|
4682
|
+
}
|
|
4683
|
+
return {
|
|
4684
|
+
status: 'finalized',
|
|
4685
|
+
outcome: this.normalizeReversionOutcome(inspection.outcome),
|
|
4686
|
+
};
|
|
4687
|
+
}
|
|
4688
|
+
if (!('reference' in inspection) || inspection.reference === undefined) {
|
|
4689
|
+
throw new FlexHarnessValidationError('Protocol-1 finalized inspection has no reference.');
|
|
4690
|
+
}
|
|
4691
|
+
return {
|
|
4692
|
+
status: 'finalized',
|
|
4693
|
+
outcome: {
|
|
4694
|
+
disposition: 'revertible',
|
|
4695
|
+
reference: this.normalizeReversionReference(inspection.reference),
|
|
4696
|
+
affectedWorkspaces: [],
|
|
4697
|
+
},
|
|
4698
|
+
};
|
|
4699
|
+
});
|
|
4700
|
+
}
|
|
4701
|
+
|
|
4702
|
+
private finalizeReversionCapture(
|
|
4703
|
+
scopeId: string,
|
|
4704
|
+
scope: TScope,
|
|
4705
|
+
storageKey: string,
|
|
4706
|
+
sessionId: string,
|
|
4707
|
+
runId: string,
|
|
4708
|
+
captureId: string,
|
|
4709
|
+
): Promise<TFlexTurnReversionFinalizedOutcome> {
|
|
4710
|
+
return this.withReversionMaintenanceSignal(async (signal) => {
|
|
4711
|
+
const result = await this.turnReversionProvider!.finalize(Object.freeze({
|
|
4712
|
+
scopeId,
|
|
4713
|
+
scope,
|
|
4714
|
+
storageKey,
|
|
4715
|
+
sessionId,
|
|
4716
|
+
runId,
|
|
4717
|
+
captureId,
|
|
4718
|
+
signal,
|
|
4719
|
+
}));
|
|
4720
|
+
return this.reversionProviderProtocolVersion() === 2
|
|
4721
|
+
? this.normalizeReversionOutcome(result)
|
|
4722
|
+
: {
|
|
4723
|
+
disposition: 'revertible',
|
|
4724
|
+
reference: this.normalizeReversionReference(result),
|
|
4725
|
+
affectedWorkspaces: [],
|
|
4726
|
+
};
|
|
4727
|
+
});
|
|
4728
|
+
}
|
|
4729
|
+
|
|
4730
|
+
private async resolveReversionCaptureOutcome(
|
|
4731
|
+
state: IStorageState,
|
|
4732
|
+
scopeId: string,
|
|
4733
|
+
scope: TScope,
|
|
4734
|
+
sessionId: string,
|
|
4735
|
+
runId: string,
|
|
4736
|
+
captureId: string,
|
|
4737
|
+
durableState: 'preparing' | 'prepared' | 'finalizing',
|
|
4738
|
+
): Promise<TFlexTurnReversionFinalizedOutcome | undefined> {
|
|
4739
|
+
let inspection;
|
|
4740
|
+
try {
|
|
4741
|
+
inspection = await this.inspectReversionCapture(
|
|
4742
|
+
scopeId,
|
|
4743
|
+
scope,
|
|
4744
|
+
state.storageKey,
|
|
4745
|
+
sessionId,
|
|
4746
|
+
runId,
|
|
4747
|
+
captureId,
|
|
4748
|
+
);
|
|
4749
|
+
} catch (error) {
|
|
4750
|
+
state.lifecycle = 'fenced';
|
|
4751
|
+
throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
|
|
4752
|
+
}
|
|
4753
|
+
if (inspection.status === 'unknown') {
|
|
4754
|
+
state.lifecycle = 'fenced';
|
|
4755
|
+
throw new FlexHarnessValidationError('Reversion capture outcome is unknown.');
|
|
4756
|
+
}
|
|
4757
|
+
if (inspection.status === 'missing') {
|
|
4758
|
+
if (durableState === 'preparing') return undefined;
|
|
4759
|
+
state.lifecycle = 'fenced';
|
|
4760
|
+
throw new FlexHarnessValidationError('Prepared reversion capture is missing.');
|
|
4761
|
+
}
|
|
4762
|
+
let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
|
|
4763
|
+
if (inspection.status === 'prepared') {
|
|
4764
|
+
try {
|
|
4765
|
+
outcome = await this.finalizeReversionCapture(
|
|
4766
|
+
scopeId,
|
|
4767
|
+
scope,
|
|
4768
|
+
state.storageKey,
|
|
4769
|
+
sessionId,
|
|
4770
|
+
runId,
|
|
4771
|
+
captureId,
|
|
4772
|
+
);
|
|
4773
|
+
} catch (error) {
|
|
4774
|
+
const finalInspection = await this.inspectReversionCapture(
|
|
4775
|
+
scopeId,
|
|
4776
|
+
scope,
|
|
4777
|
+
state.storageKey,
|
|
4778
|
+
sessionId,
|
|
4779
|
+
runId,
|
|
4780
|
+
captureId,
|
|
4781
|
+
);
|
|
4782
|
+
if (finalInspection.status !== 'finalized') {
|
|
4783
|
+
if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
|
|
4784
|
+
throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
|
|
4785
|
+
}
|
|
4786
|
+
outcome = finalInspection.outcome;
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
if (outcome === undefined) {
|
|
4790
|
+
state.lifecycle = 'fenced';
|
|
4791
|
+
throw new FlexHarnessValidationError('Finalized reversion capture has no outcome.');
|
|
4792
|
+
}
|
|
4793
|
+
return outcome;
|
|
4794
|
+
}
|
|
4795
|
+
|
|
4796
|
+
private normalizeReversionReference(reference: unknown): TJsonValue {
|
|
4797
|
+
return normalizeJsonValue(reference, {
|
|
4798
|
+
maxDepth: this.toolOutputLimits.maxDepth,
|
|
4799
|
+
maxBytes: Math.min(
|
|
4800
|
+
this.toolOutputLimits.maxBytes,
|
|
4801
|
+
FLEX_REVERSION_REFERENCE_MAX_BYTES,
|
|
4802
|
+
),
|
|
4803
|
+
});
|
|
4804
|
+
}
|
|
4805
|
+
|
|
4806
|
+
private normalizeReversionOutcome(value: unknown): TFlexTurnReversionFinalizedOutcome {
|
|
4807
|
+
if (
|
|
4808
|
+
!value
|
|
4809
|
+
|| typeof value !== 'object'
|
|
4810
|
+
|| Array.isArray(value)
|
|
4811
|
+
|| (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
4812
|
+
) throw new FlexHarnessValidationError('Turn reversion finalized outcome must be a plain object.');
|
|
4813
|
+
const outcome = value as Record<string, unknown>;
|
|
4814
|
+
const disposition = outcome.disposition;
|
|
4815
|
+
if (!['revertible', 'no-change', 'nonrevertible'].includes(String(disposition))) {
|
|
4816
|
+
throw new FlexHarnessValidationError('Turn reversion finalized outcome disposition is invalid.');
|
|
4817
|
+
}
|
|
4818
|
+
const allowedKeys = disposition === 'nonrevertible'
|
|
4819
|
+
? ['disposition', 'reference', 'reasonCode', 'affectedWorkspaces']
|
|
4820
|
+
: ['disposition', 'reference', 'affectedWorkspaces'];
|
|
4821
|
+
const unsupported = Object.keys(outcome).find((key) => !allowedKeys.includes(key));
|
|
4822
|
+
if (unsupported) {
|
|
4823
|
+
throw new FlexHarnessValidationError(
|
|
4824
|
+
`Turn reversion finalized outcome does not support "${unsupported}".`,
|
|
4825
|
+
);
|
|
4826
|
+
}
|
|
4827
|
+
if (!Object.prototype.hasOwnProperty.call(outcome, 'reference')) {
|
|
4828
|
+
throw new FlexHarnessValidationError('Turn reversion finalized outcome requires a reference.');
|
|
4829
|
+
}
|
|
4830
|
+
const reference = this.normalizeReversionReference(outcome.reference);
|
|
4831
|
+
const affectedWorkspaces = outcome.affectedWorkspaces === undefined
|
|
4832
|
+
? undefined
|
|
4833
|
+
: this.normalizeAffectedWorkspaces(outcome.affectedWorkspaces);
|
|
4834
|
+
if (disposition === 'revertible') {
|
|
4835
|
+
if (affectedWorkspaces === undefined) {
|
|
4836
|
+
throw new FlexHarnessValidationError('Revertible outcome requires affectedWorkspaces.');
|
|
4837
|
+
}
|
|
4838
|
+
return { disposition, reference, affectedWorkspaces };
|
|
4839
|
+
}
|
|
4840
|
+
if (disposition === 'no-change') {
|
|
4841
|
+
if (affectedWorkspaces && affectedWorkspaces.length > 0) {
|
|
4842
|
+
throw new FlexHarnessValidationError('No-change outcome cannot affect a workspace.');
|
|
4843
|
+
}
|
|
4844
|
+
return {
|
|
4845
|
+
disposition,
|
|
4846
|
+
reference,
|
|
4847
|
+
...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces: [] }),
|
|
4848
|
+
};
|
|
4849
|
+
}
|
|
4850
|
+
if (
|
|
4851
|
+
typeof outcome.reasonCode !== 'string'
|
|
4852
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(outcome.reasonCode)
|
|
4853
|
+
|| Buffer.byteLength(outcome.reasonCode, 'utf8') > FLEX_REVERSION_REASON_CODE_MAX_BYTES
|
|
4854
|
+
) throw new FlexHarnessValidationError('Nonrevertible outcome reasonCode is invalid.');
|
|
4855
|
+
return {
|
|
4856
|
+
disposition: 'nonrevertible',
|
|
4857
|
+
reference,
|
|
4858
|
+
reasonCode: outcome.reasonCode,
|
|
4859
|
+
...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces }),
|
|
4860
|
+
};
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4863
|
+
private normalizeAffectedWorkspaces(value: unknown): IFlexAffectedWorkspace[] {
|
|
4864
|
+
if (!Array.isArray(value) || value.length > FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
|
|
4865
|
+
throw new FlexHarnessValidationError(
|
|
4866
|
+
`affectedWorkspaces must contain at most ${FLEX_REVERSION_MAX_AFFECTED_WORKSPACES} entries.`,
|
|
4867
|
+
);
|
|
4868
|
+
}
|
|
4869
|
+
const ids = new Set<string>();
|
|
4870
|
+
return value.map((entry, index) => {
|
|
4871
|
+
if (
|
|
4872
|
+
!entry
|
|
4873
|
+
|| typeof entry !== 'object'
|
|
4874
|
+
|| Array.isArray(entry)
|
|
4875
|
+
|| (Object.getPrototypeOf(entry) !== Object.prototype && Object.getPrototypeOf(entry) !== null)
|
|
4876
|
+
) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}] must be a plain object.`);
|
|
4877
|
+
const descriptor = entry as Record<string, unknown>;
|
|
4878
|
+
const unsupported = Object.keys(descriptor).find((key) => !['id', 'label'].includes(key));
|
|
4879
|
+
if (unsupported) {
|
|
4880
|
+
throw new FlexHarnessValidationError(
|
|
4881
|
+
`affectedWorkspaces[${index}] does not support "${unsupported}".`,
|
|
4882
|
+
);
|
|
4883
|
+
}
|
|
4884
|
+
if (
|
|
4885
|
+
typeof descriptor.id !== 'string'
|
|
4886
|
+
|| !descriptor.id.trim()
|
|
4887
|
+
|| Buffer.byteLength(descriptor.id, 'utf8') > FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES
|
|
4888
|
+
) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].id is invalid.`);
|
|
4889
|
+
if (
|
|
4890
|
+
typeof descriptor.label !== 'string'
|
|
4891
|
+
|| !descriptor.label.trim()
|
|
4892
|
+
|| Buffer.byteLength(descriptor.label, 'utf8') > FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES
|
|
4893
|
+
) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].label is invalid.`);
|
|
4894
|
+
if (ids.has(descriptor.id)) {
|
|
4895
|
+
throw new FlexHarnessValidationError(`affectedWorkspaces contains duplicate id "${descriptor.id}".`);
|
|
4896
|
+
}
|
|
4897
|
+
ids.add(descriptor.id);
|
|
4898
|
+
return { id: descriptor.id, label: descriptor.label };
|
|
4899
|
+
});
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
private async prepareRunReversion(run: IActiveRun, signal: AbortSignal): Promise<void> {
|
|
4903
|
+
if (run.stored.session.agent !== undefined || !this.turnReversionProvider) return;
|
|
4904
|
+
const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
|
|
4905
|
+
const captureId = segment?.captureId;
|
|
4906
|
+
if (!segment || !captureId) {
|
|
4907
|
+
throw this.projectExternalError(
|
|
4908
|
+
run,
|
|
4909
|
+
new Error('Root generation is missing its durable reversion capture intent.'),
|
|
4910
|
+
'turnReversion',
|
|
4911
|
+
);
|
|
4912
|
+
}
|
|
4913
|
+
const context = this.reversionCaptureContext(run, captureId, signal);
|
|
4914
|
+
if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
|
|
4915
|
+
throw this.projectExternalError(
|
|
4916
|
+
run,
|
|
4917
|
+
new Error(`Root generation requires reversion protocolVersion ${segment.protocolVersion}.`),
|
|
4918
|
+
'turnReversion',
|
|
4919
|
+
);
|
|
4920
|
+
}
|
|
4921
|
+
try {
|
|
4922
|
+
await this.turnReversionProvider.prepare(context);
|
|
4923
|
+
} catch (error) {
|
|
4924
|
+
let inspection;
|
|
4925
|
+
try {
|
|
4926
|
+
inspection = await this.inspectReversionCapture(
|
|
4927
|
+
run.scopeId,
|
|
4928
|
+
run.scope as TScope,
|
|
4929
|
+
run.state.storageKey,
|
|
4930
|
+
run.sessionId,
|
|
4931
|
+
run.runId,
|
|
4932
|
+
captureId,
|
|
4933
|
+
);
|
|
4934
|
+
} catch (inspectionError) {
|
|
4935
|
+
run.state.lifecycle = 'fenced';
|
|
4936
|
+
throw this.projectExternalError(run, inspectionError, 'turnReversion');
|
|
4937
|
+
}
|
|
4938
|
+
if (inspection.status !== 'prepared' && inspection.status !== 'finalized') {
|
|
4939
|
+
if (inspection.status === 'unknown') run.state.lifecycle = 'fenced';
|
|
4940
|
+
throw this.projectExternalError(run, error, 'turnReversion');
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4944
|
+
const pending = run.stored.pendingReversion;
|
|
4945
|
+
if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
|
|
4946
|
+
throw new FlexHarnessValidationError('Reversion capture intent changed during preparation.');
|
|
4947
|
+
}
|
|
4948
|
+
pending.state = 'prepared';
|
|
4949
|
+
}, true);
|
|
4950
|
+
}
|
|
4951
|
+
|
|
4952
|
+
private async finalizeRunReversion(
|
|
4953
|
+
run: IActiveRun,
|
|
4954
|
+
status: 'completed' | 'failed' | 'cancelled',
|
|
4955
|
+
): Promise<void> {
|
|
4956
|
+
if (run.stored.session.agent !== undefined) return;
|
|
4957
|
+
const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
|
|
4958
|
+
if (!segment) return;
|
|
4959
|
+
const eventIds = run.stored.agentSession.getEvents()
|
|
4960
|
+
.filter((event) => event.generationId === run.runId)
|
|
4961
|
+
.map((event) => event.id);
|
|
4962
|
+
if (!this.turnReversionProvider || segment.provenance === 'transcript') {
|
|
4963
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4964
|
+
const wasCompleted = segment.status === 'completed';
|
|
4965
|
+
segment.status = status;
|
|
4966
|
+
segment.eventIds = eventIds;
|
|
4967
|
+
if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
|
|
4968
|
+
else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
|
|
4969
|
+
delete run.stored.pendingReversion;
|
|
4970
|
+
this.pruneReversionState(run.stored);
|
|
4971
|
+
}, true);
|
|
4972
|
+
return;
|
|
4973
|
+
}
|
|
4974
|
+
if (!segment.captureId) {
|
|
4975
|
+
throw new FlexHarnessValidationError('Workspace reversion segment has no capture ownership.');
|
|
4976
|
+
}
|
|
4977
|
+
const captureId = segment.captureId;
|
|
4978
|
+
if (segment.workspaceReference !== undefined) {
|
|
4979
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4980
|
+
const wasCompleted = segment.status === 'completed';
|
|
4981
|
+
segment.status = status;
|
|
4982
|
+
segment.eventIds = eventIds;
|
|
4983
|
+
if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
|
|
4984
|
+
else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
|
|
4985
|
+
delete run.stored.pendingReversion;
|
|
4986
|
+
}, true);
|
|
4987
|
+
return;
|
|
4988
|
+
}
|
|
4989
|
+
const pending = run.stored.pendingReversion;
|
|
4990
|
+
if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
|
|
4991
|
+
throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
|
|
4992
|
+
}
|
|
4993
|
+
if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
|
|
4994
|
+
throw new FlexHarnessValidationError(
|
|
4995
|
+
`Reversion capture finalization requires protocolVersion ${pending.protocolVersion}.`,
|
|
4996
|
+
);
|
|
4997
|
+
}
|
|
4998
|
+
if (pending.state === 'prepared') {
|
|
4999
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
5000
|
+
const current = run.stored.pendingReversion;
|
|
5001
|
+
if (current?.kind !== 'capture' || current.captureId !== captureId) {
|
|
5002
|
+
throw new FlexHarnessValidationError('Reversion capture changed before finalization.');
|
|
5003
|
+
}
|
|
5004
|
+
current.state = 'finalizing';
|
|
5005
|
+
}, true);
|
|
5006
|
+
}
|
|
5007
|
+
const outcome = await this.resolveReversionCaptureOutcome(
|
|
5008
|
+
run.state,
|
|
5009
|
+
run.scopeId,
|
|
5010
|
+
run.scope as TScope,
|
|
5011
|
+
run.sessionId,
|
|
5012
|
+
run.runId,
|
|
5013
|
+
captureId,
|
|
5014
|
+
pending.state === 'prepared' ? 'finalizing' : pending.state,
|
|
5015
|
+
);
|
|
5016
|
+
if (outcome === undefined) {
|
|
5017
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
5018
|
+
run.stored.reversionSegments = run.stored.reversionSegments.filter(
|
|
5019
|
+
(entry) => entry.runId !== run.runId,
|
|
5020
|
+
);
|
|
5021
|
+
delete run.stored.pendingReversion;
|
|
5022
|
+
}, true);
|
|
5023
|
+
return;
|
|
5024
|
+
}
|
|
5025
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
5026
|
+
const current = run.stored.pendingReversion;
|
|
5027
|
+
if (current?.kind !== 'capture' || current.runId !== run.runId) {
|
|
5028
|
+
throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
|
|
5029
|
+
}
|
|
5030
|
+
const wasCompleted = segment.status === 'completed';
|
|
5031
|
+
segment.status = status;
|
|
5032
|
+
segment.eventIds = eventIds;
|
|
5033
|
+
this.applyFinalizedReversionOutcome(run.stored, segment, outcome);
|
|
5034
|
+
if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
|
|
5035
|
+
else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
|
|
5036
|
+
delete run.stored.pendingReversion;
|
|
5037
|
+
this.pruneReversionState(run.stored);
|
|
5038
|
+
}, true);
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
private applyFinalizedReversionOutcome(
|
|
5042
|
+
stored: IStoredSessionState,
|
|
5043
|
+
segment: IFlexReversionSegment,
|
|
5044
|
+
outcome: TFlexTurnReversionFinalizedOutcome,
|
|
5045
|
+
): void {
|
|
5046
|
+
const captureId = segment.captureId;
|
|
5047
|
+
if (!captureId) {
|
|
5048
|
+
throw new FlexHarnessValidationError('Finalized workspace outcome has no capture ownership.');
|
|
5049
|
+
}
|
|
5050
|
+
segment.disposition = outcome.disposition;
|
|
5051
|
+
delete segment.affectedWorkspaces;
|
|
5052
|
+
delete segment.reasonCode;
|
|
5053
|
+
if (segment.protocolVersion === 2) {
|
|
5054
|
+
if (outcome.affectedWorkspaces !== undefined) {
|
|
5055
|
+
segment.affectedWorkspaces = cloneSerializable([...outcome.affectedWorkspaces]);
|
|
5056
|
+
}
|
|
5057
|
+
if (outcome.disposition === 'nonrevertible') segment.reasonCode = outcome.reasonCode;
|
|
5058
|
+
}
|
|
5059
|
+
if (outcome.disposition === 'revertible') {
|
|
5060
|
+
segment.workspaceReference = cloneSerializable(outcome.reference);
|
|
5061
|
+
return;
|
|
5062
|
+
}
|
|
5063
|
+
this.enqueueReversionReleaseReference(stored, {
|
|
5064
|
+
runId: segment.runId,
|
|
5065
|
+
captureId,
|
|
5066
|
+
reference: cloneSerializable(outcome.reference),
|
|
5067
|
+
protocolVersion: segment.protocolVersion,
|
|
5068
|
+
});
|
|
5069
|
+
delete segment.captureId;
|
|
5070
|
+
delete segment.workspaceReference;
|
|
5071
|
+
}
|
|
5072
|
+
|
|
5073
|
+
private hiddenReversionSegments(stored: IStoredSessionState): IFlexReversionSegment[] {
|
|
5074
|
+
const firstHidden = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
|
|
5075
|
+
if (!firstHidden) return [];
|
|
5076
|
+
const index = stored.reversionSegments.findIndex((segment) => segment.runId === firstHidden.runId);
|
|
5077
|
+
if (index < 0) return [];
|
|
5078
|
+
return stored.reversionSegments.slice(index);
|
|
5079
|
+
}
|
|
5080
|
+
|
|
5081
|
+
private enqueueReversionRelease(
|
|
5082
|
+
stored: IStoredSessionState,
|
|
5083
|
+
segment: IFlexReversionSegment,
|
|
5084
|
+
): void {
|
|
5085
|
+
if (segment.disposition !== 'revertible') return;
|
|
5086
|
+
if (segment.captureId === undefined || segment.workspaceReference === undefined) {
|
|
5087
|
+
throw new FlexHarnessValidationError('Capture-backed segment has no releasable workspace reference.');
|
|
5088
|
+
}
|
|
5089
|
+
this.enqueueReversionReleaseReference(stored, {
|
|
5090
|
+
runId: segment.runId,
|
|
5091
|
+
captureId: segment.captureId,
|
|
5092
|
+
reference: cloneSerializable(segment.workspaceReference),
|
|
5093
|
+
protocolVersion: segment.protocolVersion,
|
|
5094
|
+
});
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
private enqueueReversionReleaseReference(
|
|
5098
|
+
stored: IStoredSessionState,
|
|
5099
|
+
release: IFlexPendingReversionRelease,
|
|
5100
|
+
): void {
|
|
5101
|
+
if (stored.pendingReversionReleases.some((entry) => entry.captureId === release.captureId)) {
|
|
5102
|
+
return;
|
|
5103
|
+
}
|
|
5104
|
+
if (stored.pendingReversionReleases.length >= this.reversionLimits.maxPendingReversionReleases) {
|
|
5105
|
+
throw new FlexHarnessValidationError('Pending reversion release limit was reached.');
|
|
5106
|
+
}
|
|
5107
|
+
stored.pendingReversionReleases.push(cloneSerializable(release));
|
|
5108
|
+
}
|
|
5109
|
+
|
|
5110
|
+
private pruneReversionState(
|
|
5111
|
+
stored: IStoredSessionState,
|
|
5112
|
+
reserveSegment = false,
|
|
5113
|
+
dropNonUndoablePrefix = false,
|
|
5114
|
+
): void {
|
|
5115
|
+
if (stored.pendingReversion || stored.revertCursor !== this.completedReversionCandidates(stored).length) {
|
|
5116
|
+
return;
|
|
5117
|
+
}
|
|
5118
|
+
while (stored.reversionSegments.length > 0) {
|
|
5119
|
+
const candidates = this.completedReversionCandidates(stored);
|
|
5120
|
+
const overLimit = candidates.length + (reserveSegment ? 1 : 0)
|
|
5121
|
+
> this.reversionLimits.maxCompletedTurns
|
|
5122
|
+
|| stored.reversionSegments.length + (reserveSegment ? 1 : 0)
|
|
5123
|
+
> this.reversionLimits.maxSegments;
|
|
5124
|
+
const firstCandidate = candidates[0];
|
|
5125
|
+
const nextCandidate = candidates[1];
|
|
5126
|
+
let end = 0;
|
|
5127
|
+
if (!firstCandidate) {
|
|
5128
|
+
if (dropNonUndoablePrefix) end = stored.reversionSegments.length;
|
|
5129
|
+
else if (overLimit) {
|
|
5130
|
+
end = Math.max(
|
|
5131
|
+
1,
|
|
5132
|
+
stored.reversionSegments.length + (reserveSegment ? 1 : 0)
|
|
5133
|
+
- this.reversionLimits.maxSegments,
|
|
5134
|
+
);
|
|
5135
|
+
} else break;
|
|
5136
|
+
} else if (!firstCandidate.contextAvailable) {
|
|
5137
|
+
end = nextCandidate
|
|
5138
|
+
? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
|
|
5139
|
+
: stored.reversionSegments.length;
|
|
5140
|
+
} else if (overLimit) {
|
|
5141
|
+
end = nextCandidate
|
|
5142
|
+
? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
|
|
5143
|
+
: stored.reversionSegments.length;
|
|
5144
|
+
} else {
|
|
5145
|
+
break;
|
|
5146
|
+
}
|
|
5147
|
+
if (end <= 0) break;
|
|
5148
|
+
const prefix = stored.reversionSegments.slice(0, end);
|
|
5149
|
+
if (prefix.some((segment) =>
|
|
5150
|
+
segment.disposition === 'revertible' && segment.workspaceReference === undefined)) break;
|
|
5151
|
+
for (const segment of prefix) this.enqueueReversionRelease(stored, segment);
|
|
5152
|
+
stored.reversionSegments.splice(0, end);
|
|
5153
|
+
stored.revertCursor = Math.max(
|
|
5154
|
+
0,
|
|
5155
|
+
stored.revertCursor - prefix.filter((segment) => segment.status === 'completed').length,
|
|
5156
|
+
);
|
|
5157
|
+
}
|
|
5158
|
+
if (stored.excludedRunIds.length > this.reversionLimits.maxExcludedRunIds) {
|
|
5159
|
+
throw new FlexHarnessValidationError('Excluded reversion run limit was reached.');
|
|
5160
|
+
}
|
|
5161
|
+
}
|
|
5162
|
+
|
|
5163
|
+
private async commitRevertedBranch(
|
|
5164
|
+
state: IStorageState,
|
|
5165
|
+
stored: IStoredSessionState,
|
|
5166
|
+
scopeId: string,
|
|
5167
|
+
scope: TScope,
|
|
5168
|
+
): Promise<void> {
|
|
5169
|
+
if (stored.pendingReversionReleases.length > 0) {
|
|
5170
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
5171
|
+
this.assertStateAcceptingWork(state);
|
|
5172
|
+
}
|
|
5173
|
+
if (this.hiddenReversionSegments(stored).length === 0) {
|
|
5174
|
+
return;
|
|
5175
|
+
}
|
|
5176
|
+
await this.mutateProjection(state, stored, () => this.commitRevertedBranchState(stored), true);
|
|
5177
|
+
this.emitEvent(scopeId, stored.session.sessionId, {
|
|
5178
|
+
type: 'session.history.changed',
|
|
5179
|
+
direction: 'branch',
|
|
5180
|
+
});
|
|
5181
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
5182
|
+
}
|
|
5183
|
+
|
|
5184
|
+
private commitRevertedBranchState(stored: IStoredSessionState): boolean {
|
|
5185
|
+
const hidden = this.hiddenReversionSegments(stored);
|
|
5186
|
+
if (hidden.length === 0) return false;
|
|
5187
|
+
const hiddenRunIds = new Set(hidden.map((segment) => segment.runId));
|
|
5188
|
+
stored.messages = stored.messages.filter((message) => !hiddenRunIds.has(message.runId));
|
|
5189
|
+
stored.stagedTerminals = stored.stagedTerminals.filter(
|
|
5190
|
+
(terminal) => !hiddenRunIds.has(terminal.runId),
|
|
5191
|
+
);
|
|
5192
|
+
for (const segment of hidden) {
|
|
5193
|
+
this.enqueueReversionRelease(stored, segment);
|
|
5194
|
+
if (!stored.excludedRunIds.includes(segment.runId)) stored.excludedRunIds.push(segment.runId);
|
|
5195
|
+
}
|
|
5196
|
+
stored.reversionSegments = stored.reversionSegments.filter(
|
|
5197
|
+
(segment) => !hiddenRunIds.has(segment.runId),
|
|
5198
|
+
);
|
|
5199
|
+
stored.revertCursor = this.completedReversionCandidates(stored).length;
|
|
5200
|
+
this.pruneReversionState(stored);
|
|
5201
|
+
return true;
|
|
5202
|
+
}
|
|
5203
|
+
|
|
5204
|
+
private commitArchivedHiddenBranchState(stored: IStoredSessionState): boolean {
|
|
5205
|
+
const hidden = this.hiddenReversionSegments(stored);
|
|
5206
|
+
return hidden.some((segment) => !segment.contextAvailable)
|
|
5207
|
+
? this.commitRevertedBranchState(stored)
|
|
5208
|
+
: false;
|
|
5209
|
+
}
|
|
5210
|
+
|
|
5211
|
+
private filterReversionContextEvents(
|
|
5212
|
+
stored: IStoredSessionState,
|
|
5213
|
+
events: readonly plugins.TAgentEvent[],
|
|
5214
|
+
): plugins.TAgentEvent[] {
|
|
5215
|
+
const hiddenRunIds = new Set([
|
|
5216
|
+
...this.hiddenReversionSegments(stored).map((segment) => segment.runId),
|
|
5217
|
+
...stored.excludedRunIds,
|
|
5218
|
+
]);
|
|
5219
|
+
const hiddenRawIds = new Set([
|
|
5220
|
+
...this.hiddenReversionSegments(stored).flatMap((segment) => segment.eventIds),
|
|
5221
|
+
...events.filter((event) => event.generationId && hiddenRunIds.has(event.generationId))
|
|
5222
|
+
.map((event) => event.id),
|
|
5223
|
+
]);
|
|
5224
|
+
const taintedCompactionIds = new Set<string>();
|
|
5225
|
+
let changed = true;
|
|
5226
|
+
while (changed) {
|
|
5227
|
+
changed = false;
|
|
5228
|
+
for (const event of events) {
|
|
5229
|
+
if (event.type !== 'context-compaction' || taintedCompactionIds.has(event.id)) continue;
|
|
5230
|
+
if (
|
|
5231
|
+
event.coveredEventIds.some((id) => hiddenRawIds.has(id) || taintedCompactionIds.has(id))
|
|
5232
|
+
|| event.archivedTransactions?.some((transaction) =>
|
|
5233
|
+
hiddenRunIds.has(transaction.generationId))
|
|
5234
|
+
) {
|
|
5235
|
+
taintedCompactionIds.add(event.id);
|
|
5236
|
+
changed = true;
|
|
5237
|
+
}
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
5240
|
+
return events.filter((event) =>
|
|
5241
|
+
!(event.generationId && hiddenRunIds.has(event.generationId))
|
|
5242
|
+
&& !taintedCompactionIds.has(event.id));
|
|
5243
|
+
}
|
|
5244
|
+
|
|
5245
|
+
private async drainReversionReleases(
|
|
5246
|
+
state: IStorageState,
|
|
5247
|
+
stored: IStoredSessionState,
|
|
5248
|
+
scopeId: string,
|
|
5249
|
+
scope: TScope,
|
|
5250
|
+
throwOnFailure: boolean,
|
|
5251
|
+
): Promise<void> {
|
|
5252
|
+
await this.reconcileProjectionFromStore(stored);
|
|
5253
|
+
if (!this.turnReversionProvider) {
|
|
5254
|
+
if (stored.pendingReversionReleases.length > 0) {
|
|
5255
|
+
throw new FlexHarnessValidationError(
|
|
5256
|
+
'Pending workspace capture releases require their turn reversion provider.',
|
|
5257
|
+
);
|
|
5258
|
+
}
|
|
5259
|
+
return;
|
|
5260
|
+
}
|
|
5261
|
+
while (stored.pendingReversionReleases.length > 0) {
|
|
5262
|
+
const release = stored.pendingReversionReleases[0];
|
|
5263
|
+
if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
|
|
5264
|
+
throw new FlexHarnessValidationError(
|
|
5265
|
+
`Pending workspace release requires protocolVersion ${release.protocolVersion}.`,
|
|
5266
|
+
);
|
|
5267
|
+
}
|
|
5268
|
+
try {
|
|
5269
|
+
await this.withReversionMaintenanceSignal((signal) =>
|
|
5270
|
+
this.turnReversionProvider!.release(Object.freeze({
|
|
5271
|
+
scopeId,
|
|
5272
|
+
scope,
|
|
5273
|
+
storageKey: state.storageKey,
|
|
5274
|
+
sessionId: stored.session.sessionId,
|
|
5275
|
+
runId: release.runId,
|
|
5276
|
+
captureId: release.captureId,
|
|
5277
|
+
reference: cloneSerializable(release.reference),
|
|
5278
|
+
signal,
|
|
5279
|
+
})));
|
|
5280
|
+
} catch (error) {
|
|
5281
|
+
if (throwOnFailure) {
|
|
5282
|
+
throw this.projectOperationError(
|
|
5283
|
+
error,
|
|
5284
|
+
'turnReversion',
|
|
5285
|
+
scopeId,
|
|
5286
|
+
stored.session.sessionId,
|
|
5287
|
+
`release:${release.captureId}`,
|
|
5288
|
+
);
|
|
5289
|
+
}
|
|
5290
|
+
return;
|
|
5291
|
+
}
|
|
5292
|
+
try {
|
|
5293
|
+
await this.mutateProjection(state, stored, () => {
|
|
5294
|
+
if (stored.pendingReversionReleases[0]?.captureId === release.captureId) {
|
|
5295
|
+
stored.pendingReversionReleases.shift();
|
|
5296
|
+
}
|
|
5297
|
+
}, true);
|
|
5298
|
+
} catch (error) {
|
|
5299
|
+
if (!throwOnFailure && state.lifecycle === 'fenced') {
|
|
5300
|
+
this.deferReversionReleaseDrain(state, stored, scopeId, scope);
|
|
5301
|
+
}
|
|
5302
|
+
if (throwOnFailure) throw error;
|
|
5303
|
+
return;
|
|
5304
|
+
}
|
|
5305
|
+
if (stored.projectionReconciliationRequired) return;
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
|
|
5309
|
+
private async releaseDeletedSessionReversions(
|
|
5310
|
+
state: IStorageState,
|
|
5311
|
+
storageKey: string,
|
|
5312
|
+
sessionId: string,
|
|
5313
|
+
scopeId: string,
|
|
5314
|
+
scope: TScope,
|
|
5315
|
+
stored?: IStoredSessionState,
|
|
5316
|
+
): Promise<void> {
|
|
5317
|
+
if (stored) await stored.projectionQueue;
|
|
5318
|
+
let durableProjection = await this.stores.projections.load(storageKey, sessionId);
|
|
5319
|
+
if (!durableProjection) return;
|
|
5320
|
+
let projection = this.upgradeProjectionSnapshot(durableProjection);
|
|
5321
|
+
const persistDirect = async (next: IFlexProjectionSnapshotCurrent): Promise<void> => {
|
|
5322
|
+
const before = durableProjection;
|
|
5323
|
+
try {
|
|
5324
|
+
await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
|
|
5325
|
+
durableProjection = next;
|
|
5326
|
+
projection = next;
|
|
5327
|
+
} catch (error) {
|
|
5328
|
+
let current: TFlexProjectionSnapshot | undefined;
|
|
5329
|
+
let reconciliationError: unknown;
|
|
5330
|
+
try {
|
|
5331
|
+
current = await this.stores.projections.load(storageKey, sessionId);
|
|
5332
|
+
} catch (loadError) {
|
|
5333
|
+
reconciliationError = loadError;
|
|
5334
|
+
}
|
|
5335
|
+
if (current && JSON.stringify(current) === JSON.stringify(next)) {
|
|
5336
|
+
durableProjection = current;
|
|
5337
|
+
projection = next;
|
|
5338
|
+
return;
|
|
5339
|
+
}
|
|
5340
|
+
if (current && before && JSON.stringify(current) === JSON.stringify(before)) throw error;
|
|
5341
|
+
state.lifecycle = 'fenced';
|
|
5342
|
+
throw reconciliationError === undefined ? error : combineErrors([error, reconciliationError]);
|
|
5343
|
+
}
|
|
5344
|
+
};
|
|
5345
|
+
if (projection.pendingReversion?.kind === 'apply') {
|
|
5346
|
+
if (stored) {
|
|
5347
|
+
await this.resumePendingApply(state, stored, scopeId, scope);
|
|
5348
|
+
} else {
|
|
5349
|
+
const pending = projection.pendingReversion;
|
|
5350
|
+
const orderedRunIds = pending.direction === 'undo'
|
|
5351
|
+
? [...pending.segmentRunIds].reverse()
|
|
5352
|
+
: [...pending.segmentRunIds];
|
|
5353
|
+
for (const runId of orderedRunIds) {
|
|
5354
|
+
if (pending.appliedRunIds.includes(runId)) continue;
|
|
5355
|
+
const segment = projection.reversionSegments.find((entry) => entry.runId === runId)!;
|
|
5356
|
+
if (segment.disposition === 'revertible') {
|
|
5357
|
+
if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
|
|
5358
|
+
throw new FlexHarnessValidationError(
|
|
5359
|
+
'Pending workspace reversion deletion requires its turn reversion provider.',
|
|
5360
|
+
);
|
|
5361
|
+
}
|
|
5362
|
+
if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
|
|
5363
|
+
throw new FlexHarnessValidationError(
|
|
5364
|
+
`Pending workspace reversion deletion requires protocolVersion ${segment.protocolVersion}.`,
|
|
5365
|
+
);
|
|
5366
|
+
}
|
|
5367
|
+
const captureId = segment.captureId;
|
|
5368
|
+
const reference = segment.workspaceReference;
|
|
5369
|
+
const operationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
|
|
5370
|
+
const createContext = (signal: AbortSignal) => Object.freeze({
|
|
5371
|
+
scopeId,
|
|
5372
|
+
scope,
|
|
5373
|
+
storageKey,
|
|
5374
|
+
sessionId,
|
|
5375
|
+
runId,
|
|
5376
|
+
captureId,
|
|
5377
|
+
reference: cloneSerializable(reference),
|
|
5378
|
+
operationId,
|
|
5379
|
+
direction: pending.direction,
|
|
5380
|
+
signal,
|
|
5381
|
+
});
|
|
5382
|
+
let inspection = await this.withReversionMaintenanceSignal((signal) =>
|
|
5383
|
+
this.turnReversionProvider!.inspectApply(createContext(signal)));
|
|
5384
|
+
if (inspection.status === 'unknown') {
|
|
5385
|
+
state.lifecycle = 'fenced';
|
|
5386
|
+
throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
|
|
5387
|
+
}
|
|
5388
|
+
if (inspection.status === 'not-applied') {
|
|
5389
|
+
try {
|
|
5390
|
+
await this.withReversionMaintenanceSignal((signal) =>
|
|
5391
|
+
this.turnReversionProvider!.apply(createContext(signal)));
|
|
5392
|
+
} catch (error) {
|
|
5393
|
+
inspection = await this.withReversionMaintenanceSignal((signal) =>
|
|
5394
|
+
this.turnReversionProvider!.inspectApply(createContext(signal)));
|
|
5395
|
+
if (inspection.status !== 'applied') {
|
|
5396
|
+
if (inspection.status === 'unknown') state.lifecycle = 'fenced';
|
|
5397
|
+
throw error;
|
|
5398
|
+
}
|
|
5399
|
+
}
|
|
5400
|
+
}
|
|
5401
|
+
}
|
|
5402
|
+
pending.appliedRunIds.push(runId);
|
|
5403
|
+
await persistDirect({ ...projection, revision: projection.revision + 1 });
|
|
5404
|
+
}
|
|
5405
|
+
const { pendingReversion: _pendingReversion, ...completedProjection } = projection;
|
|
5406
|
+
const completed: IFlexProjectionSnapshotCurrent = {
|
|
5407
|
+
...completedProjection,
|
|
5408
|
+
revision: projection.revision + 1,
|
|
5409
|
+
revertCursor: pending.toCursor,
|
|
5410
|
+
};
|
|
5411
|
+
await persistDirect(completed);
|
|
5412
|
+
}
|
|
5413
|
+
const recoveredProjection = await this.stores.projections.load(storageKey, sessionId);
|
|
5414
|
+
if (!recoveredProjection) return;
|
|
5415
|
+
durableProjection = recoveredProjection;
|
|
5416
|
+
projection = this.upgradeProjectionSnapshot(recoveredProjection);
|
|
5417
|
+
}
|
|
5418
|
+
const releases = new Map<string, IFlexPendingReversionRelease>();
|
|
5419
|
+
for (const release of projection.pendingReversionReleases) {
|
|
5420
|
+
releases.set(release.captureId, cloneSerializable(release));
|
|
5421
|
+
}
|
|
5422
|
+
for (const segment of projection.reversionSegments) {
|
|
5423
|
+
if (segment.captureId && segment.workspaceReference !== undefined) {
|
|
5424
|
+
releases.set(segment.captureId, {
|
|
5425
|
+
runId: segment.runId,
|
|
5426
|
+
captureId: segment.captureId,
|
|
5427
|
+
reference: cloneSerializable(segment.workspaceReference),
|
|
5428
|
+
protocolVersion: segment.protocolVersion,
|
|
5429
|
+
});
|
|
5430
|
+
}
|
|
5431
|
+
}
|
|
5432
|
+
const unresolvedCaptures = new Map<string, {
|
|
5433
|
+
runId: string;
|
|
5434
|
+
captureId: string;
|
|
5435
|
+
protocolVersion: 1 | 2;
|
|
5436
|
+
durableState?: 'preparing' | 'prepared' | 'finalizing';
|
|
5437
|
+
}>();
|
|
5438
|
+
for (const segment of projection.reversionSegments) {
|
|
5439
|
+
if (segment.captureId && !releases.has(segment.captureId)) {
|
|
5440
|
+
unresolvedCaptures.set(segment.captureId, {
|
|
5441
|
+
runId: segment.runId,
|
|
5442
|
+
captureId: segment.captureId,
|
|
5443
|
+
protocolVersion: segment.protocolVersion,
|
|
3050
5444
|
});
|
|
3051
5445
|
}
|
|
3052
|
-
assistantMessage.usage = cloneSerializable(result.usage);
|
|
3053
|
-
} else {
|
|
3054
|
-
const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
|
|
3055
|
-
userMessage.error = message;
|
|
3056
|
-
assistantMessage.error = message;
|
|
3057
5446
|
}
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
5447
|
+
if (projection.pendingReversion?.kind === 'capture') {
|
|
5448
|
+
unresolvedCaptures.set(projection.pendingReversion.captureId, {
|
|
5449
|
+
runId: projection.pendingReversion.runId,
|
|
5450
|
+
captureId: projection.pendingReversion.captureId,
|
|
5451
|
+
protocolVersion: projection.pendingReversion.protocolVersion,
|
|
5452
|
+
durableState: projection.pendingReversion.state,
|
|
5453
|
+
});
|
|
5454
|
+
}
|
|
5455
|
+
if (unresolvedCaptures.size > 0 && !this.turnReversionProvider) {
|
|
5456
|
+
throw new FlexHarnessValidationError(
|
|
5457
|
+
'Capture-backed session deletion requires its turn reversion provider.',
|
|
5458
|
+
);
|
|
5459
|
+
}
|
|
5460
|
+
for (const capture of unresolvedCaptures.values()) {
|
|
5461
|
+
if (this.reversionProviderProtocolVersion() !== capture.protocolVersion) {
|
|
5462
|
+
throw new FlexHarnessValidationError(
|
|
5463
|
+
`Deleted session capture requires protocolVersion ${capture.protocolVersion}.`,
|
|
5464
|
+
);
|
|
5465
|
+
}
|
|
5466
|
+
if (capture.durableState === 'prepared') {
|
|
5467
|
+
if (stored) {
|
|
5468
|
+
await this.mutateProjection(state, stored, () => {
|
|
5469
|
+
const pending = stored.pendingReversion;
|
|
5470
|
+
if (pending?.kind !== 'capture' || pending.captureId !== capture.captureId) {
|
|
5471
|
+
throw new FlexHarnessValidationError('Deleted session capture changed before finalization.');
|
|
5472
|
+
}
|
|
5473
|
+
pending.state = 'finalizing';
|
|
5474
|
+
}, true);
|
|
5475
|
+
} else if (projection.pendingReversion?.kind === 'capture') {
|
|
5476
|
+
projection.pendingReversion.state = 'finalizing';
|
|
5477
|
+
await persistDirect({ ...projection, revision: projection.revision + 1 });
|
|
5478
|
+
}
|
|
5479
|
+
capture.durableState = 'finalizing';
|
|
5480
|
+
}
|
|
5481
|
+
let inspection;
|
|
5482
|
+
try {
|
|
5483
|
+
inspection = await this.inspectReversionCapture(
|
|
5484
|
+
scopeId,
|
|
5485
|
+
scope,
|
|
5486
|
+
storageKey,
|
|
5487
|
+
sessionId,
|
|
5488
|
+
capture.runId,
|
|
5489
|
+
capture.captureId,
|
|
5490
|
+
);
|
|
5491
|
+
} catch (error) {
|
|
5492
|
+
throw this.projectOperationError(
|
|
5493
|
+
error,
|
|
5494
|
+
'turnReversion',
|
|
5495
|
+
scopeId,
|
|
5496
|
+
sessionId,
|
|
5497
|
+
`release:${capture.captureId}`,
|
|
5498
|
+
);
|
|
5499
|
+
}
|
|
5500
|
+
if (inspection.status === 'unknown') {
|
|
5501
|
+
state.lifecycle = 'fenced';
|
|
5502
|
+
throw new FlexHarnessValidationError('Deleted session capture outcome is unknown.');
|
|
5503
|
+
}
|
|
5504
|
+
if (inspection.status === 'missing') {
|
|
5505
|
+
if (capture.durableState === 'preparing') continue;
|
|
5506
|
+
state.lifecycle = 'fenced';
|
|
5507
|
+
throw new FlexHarnessValidationError('Prepared deleted-session capture is missing.');
|
|
5508
|
+
}
|
|
5509
|
+
let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
|
|
5510
|
+
if (inspection.status === 'prepared') {
|
|
5511
|
+
try {
|
|
5512
|
+
outcome = await this.finalizeReversionCapture(
|
|
5513
|
+
scopeId,
|
|
5514
|
+
scope,
|
|
5515
|
+
storageKey,
|
|
5516
|
+
sessionId,
|
|
5517
|
+
capture.runId,
|
|
5518
|
+
capture.captureId,
|
|
5519
|
+
);
|
|
5520
|
+
} catch (error) {
|
|
5521
|
+
const finalInspection = await this.inspectReversionCapture(
|
|
5522
|
+
scopeId,
|
|
5523
|
+
scope,
|
|
5524
|
+
storageKey,
|
|
5525
|
+
sessionId,
|
|
5526
|
+
capture.runId,
|
|
5527
|
+
capture.captureId,
|
|
5528
|
+
);
|
|
5529
|
+
if (finalInspection.status !== 'finalized') {
|
|
5530
|
+
if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
|
|
5531
|
+
throw this.projectOperationError(
|
|
5532
|
+
error,
|
|
5533
|
+
'turnReversion',
|
|
5534
|
+
scopeId,
|
|
5535
|
+
sessionId,
|
|
5536
|
+
`release:${capture.captureId}`,
|
|
5537
|
+
);
|
|
3069
5538
|
}
|
|
3070
|
-
|
|
3071
|
-
|
|
5539
|
+
outcome = finalInspection.outcome;
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
if (outcome === undefined) {
|
|
5543
|
+
throw new FlexHarnessValidationError('Deleted session capture has no release reference.');
|
|
5544
|
+
}
|
|
5545
|
+
releases.set(capture.captureId, {
|
|
5546
|
+
runId: capture.runId,
|
|
5547
|
+
captureId: capture.captureId,
|
|
5548
|
+
reference: cloneSerializable(outcome.reference),
|
|
5549
|
+
protocolVersion: capture.protocolVersion,
|
|
5550
|
+
});
|
|
5551
|
+
}
|
|
5552
|
+
if (releases.size === 0) return;
|
|
5553
|
+
if (!this.turnReversionProvider) {
|
|
5554
|
+
throw new FlexHarnessValidationError(
|
|
5555
|
+
'Capture-backed session deletion requires its turn reversion provider.',
|
|
5556
|
+
);
|
|
5557
|
+
}
|
|
5558
|
+
if (stored) {
|
|
5559
|
+
await this.mutateProjection(state, stored, () => {
|
|
5560
|
+
stored.reversionSegments = [];
|
|
5561
|
+
stored.revertCursor = 0;
|
|
5562
|
+
delete stored.pendingReversion;
|
|
5563
|
+
stored.pendingReversionReleases = [...releases.values()].map((release) =>
|
|
5564
|
+
cloneSerializable(release));
|
|
5565
|
+
}, true);
|
|
5566
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, true);
|
|
5567
|
+
} else {
|
|
5568
|
+
const { pendingReversion: _pendingReversion, ...withoutPending } = projection;
|
|
5569
|
+
await persistDirect({
|
|
5570
|
+
...withoutPending,
|
|
5571
|
+
revision: projection.revision + 1,
|
|
5572
|
+
reversionSegments: [],
|
|
5573
|
+
revertCursor: 0,
|
|
5574
|
+
pendingReversionReleases: [...releases.values()].map((release) =>
|
|
5575
|
+
cloneSerializable(release)),
|
|
5576
|
+
});
|
|
5577
|
+
while (projection.pendingReversionReleases.length > 0) {
|
|
5578
|
+
const release = projection.pendingReversionReleases[0];
|
|
5579
|
+
if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
|
|
5580
|
+
throw new FlexHarnessValidationError(
|
|
5581
|
+
`Deleted session release requires protocolVersion ${release.protocolVersion}.`,
|
|
5582
|
+
);
|
|
5583
|
+
}
|
|
5584
|
+
try {
|
|
5585
|
+
await this.withReversionMaintenanceSignal((signal) =>
|
|
5586
|
+
this.turnReversionProvider!.release(Object.freeze({
|
|
5587
|
+
scopeId,
|
|
5588
|
+
scope,
|
|
5589
|
+
storageKey,
|
|
5590
|
+
sessionId,
|
|
5591
|
+
runId: release.runId,
|
|
5592
|
+
captureId: release.captureId,
|
|
5593
|
+
reference: cloneSerializable(release.reference),
|
|
5594
|
+
signal,
|
|
5595
|
+
})));
|
|
5596
|
+
} catch (error) {
|
|
5597
|
+
throw this.projectOperationError(
|
|
5598
|
+
error,
|
|
5599
|
+
'turnReversion',
|
|
5600
|
+
scopeId,
|
|
5601
|
+
sessionId,
|
|
5602
|
+
`release:${release.captureId}`,
|
|
5603
|
+
);
|
|
5604
|
+
}
|
|
5605
|
+
await persistDirect({
|
|
5606
|
+
...projection,
|
|
5607
|
+
revision: projection.revision + 1,
|
|
5608
|
+
pendingReversionReleases: projection.pendingReversionReleases.slice(1),
|
|
5609
|
+
});
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
3072
5612
|
}
|
|
3073
5613
|
|
|
3074
5614
|
private async promoteCompletedTerminal(
|
|
@@ -3688,6 +6228,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3688
6228
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
3689
6229
|
run.stored.messages = run.stored.messages.filter((message) => message.runId !== run.runId);
|
|
3690
6230
|
run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
|
|
6231
|
+
run.stored.reversionSegments = run.stored.reversionSegments.filter(
|
|
6232
|
+
(segment) => segment.runId !== run.runId,
|
|
6233
|
+
);
|
|
6234
|
+
if (
|
|
6235
|
+
run.stored.pendingReversion?.kind === 'capture'
|
|
6236
|
+
&& run.stored.pendingReversion.runId === run.runId
|
|
6237
|
+
) delete run.stored.pendingReversion;
|
|
3691
6238
|
}, true);
|
|
3692
6239
|
} catch (error) {
|
|
3693
6240
|
errors.push(this.projectExternalError(run, error, 'persistence'));
|
|
@@ -4060,6 +6607,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4060
6607
|
storageKey,
|
|
4061
6608
|
compactorLifecycleController,
|
|
4062
6609
|
scopeIdHint: scopeId,
|
|
6610
|
+
scopeContext: { scopeId, scope },
|
|
4063
6611
|
revision: snapshot.revision,
|
|
4064
6612
|
sessions: new Map(),
|
|
4065
6613
|
retainedSessionCleanups: new Map(),
|
|
@@ -4084,7 +6632,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4084
6632
|
const stored = await this.loadSessionRuntime(state, metadata, scopeId, scope);
|
|
4085
6633
|
state.sessions.set(metadata.sessionId, stored);
|
|
4086
6634
|
loadedSessions.push(stored);
|
|
4087
|
-
scopeChanged = (await this.repairLoadedSession(
|
|
6635
|
+
scopeChanged = (await this.repairLoadedSession(
|
|
6636
|
+
state.storageKey,
|
|
6637
|
+
stored,
|
|
6638
|
+
state,
|
|
6639
|
+
scopeId,
|
|
6640
|
+
scope,
|
|
6641
|
+
)) || scopeChanged;
|
|
6642
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
4088
6643
|
if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
|
|
4089
6644
|
}
|
|
4090
6645
|
for (const stored of state.sessions.values()) {
|
|
@@ -4115,12 +6670,23 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4115
6670
|
try {
|
|
4116
6671
|
const group = this.tombstoneGroup(state, rootSessionId);
|
|
4117
6672
|
for (const tombstone of group) {
|
|
6673
|
+
await this.releaseDeletedSessionReversions(
|
|
6674
|
+
state,
|
|
6675
|
+
storageKey,
|
|
6676
|
+
tombstone.sessionId,
|
|
6677
|
+
scopeId,
|
|
6678
|
+
scope,
|
|
6679
|
+
);
|
|
4118
6680
|
await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
|
|
4119
6681
|
}
|
|
4120
6682
|
for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
|
|
4121
6683
|
scopeChanged = true;
|
|
4122
|
-
} catch {
|
|
6684
|
+
} catch (error) {
|
|
4123
6685
|
// A retained tombstone is retried by the next load or explicit delete call.
|
|
6686
|
+
if (state.lifecycle === 'fenced') {
|
|
6687
|
+
this.deferNamespaceDrain(state, error, { scopeId, scope });
|
|
6688
|
+
break;
|
|
6689
|
+
}
|
|
4124
6690
|
}
|
|
4125
6691
|
}
|
|
4126
6692
|
if (scopeChanged) {
|
|
@@ -4130,6 +6696,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4130
6696
|
}
|
|
4131
6697
|
return state;
|
|
4132
6698
|
} catch (error) {
|
|
6699
|
+
for (const rootSessionId of new Set([...state.tombstones.values()].map((tombstone) =>
|
|
6700
|
+
tombstone.rootSessionId ?? tombstone.sessionId))) {
|
|
6701
|
+
const key = JSON.stringify([storageKey, rootSessionId]);
|
|
6702
|
+
this.orphanedTombstoneOwners.set(key, {
|
|
6703
|
+
state,
|
|
6704
|
+
rootSessionId,
|
|
6705
|
+
scopeId,
|
|
6706
|
+
scope,
|
|
6707
|
+
});
|
|
6708
|
+
}
|
|
4133
6709
|
const closeResults = await Promise.allSettled(
|
|
4134
6710
|
loadedSessions.map((stored) => this.closeStoredSession(stored)),
|
|
4135
6711
|
);
|
|
@@ -4162,6 +6738,12 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4162
6738
|
session,
|
|
4163
6739
|
messages: [],
|
|
4164
6740
|
stagedTerminals: [],
|
|
6741
|
+
reversionSegments: [],
|
|
6742
|
+
revertCursor: 0,
|
|
6743
|
+
excludedRunIds: [],
|
|
6744
|
+
pendingReversionReleases: [],
|
|
6745
|
+
projectionSchemaVersion: 3,
|
|
6746
|
+
projectionReconciliationRequired: false,
|
|
4165
6747
|
projectionRevision: 0,
|
|
4166
6748
|
projectionQueue: Promise.resolve(),
|
|
4167
6749
|
rememberedPermissionKeys: new Set(),
|
|
@@ -4228,6 +6810,28 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4228
6810
|
throw combineErrors(acquisitionErrors);
|
|
4229
6811
|
}
|
|
4230
6812
|
const projection = projectionResult.value;
|
|
6813
|
+
const legacyProjection = projection?.schemaVersion === 2 ? projection : undefined;
|
|
6814
|
+
const currentProjection = projection?.schemaVersion === 3 ? projection : undefined;
|
|
6815
|
+
const reversionSegments: IFlexReversionSegment[] = currentProjection
|
|
6816
|
+
? cloneSerializable(currentProjection.reversionSegments)
|
|
6817
|
+
: (legacyProjection?.reversionSegments ?? []).map((segment): IFlexReversionSegment => ({
|
|
6818
|
+
...cloneSerializable(segment),
|
|
6819
|
+
protocolVersion: 1,
|
|
6820
|
+
provenance: segment.workspaceCaptured ? 'workspace' : 'transcript',
|
|
6821
|
+
...(segment.workspaceCaptured
|
|
6822
|
+
? { disposition: segment.status === 'capturing' ? 'pending' : 'revertible' }
|
|
6823
|
+
: {}),
|
|
6824
|
+
}));
|
|
6825
|
+
const pendingReversion = currentProjection?.pendingReversion
|
|
6826
|
+
?? (legacyProjection?.pendingReversion?.kind === 'capture'
|
|
6827
|
+
? { ...legacyProjection.pendingReversion, protocolVersion: 1 as const }
|
|
6828
|
+
: legacyProjection?.pendingReversion);
|
|
6829
|
+
const pendingReversionReleases: IFlexPendingReversionRelease[] = currentProjection
|
|
6830
|
+
? cloneSerializable(currentProjection.pendingReversionReleases)
|
|
6831
|
+
: (legacyProjection?.pendingReversionReleases ?? []).map((release) => ({
|
|
6832
|
+
...cloneSerializable(release),
|
|
6833
|
+
protocolVersion: 1,
|
|
6834
|
+
}));
|
|
4231
6835
|
const permission = permissionResult.value;
|
|
4232
6836
|
const eventStore = eventStoreResult.value;
|
|
4233
6837
|
const jobStore = jobStoreResult.value;
|
|
@@ -4258,6 +6862,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4258
6862
|
session: cloneSerializable(metadata),
|
|
4259
6863
|
messages: cloneSerializable(projection?.messages ?? []),
|
|
4260
6864
|
stagedTerminals: cloneSerializable(projection?.stagedTerminals ?? []),
|
|
6865
|
+
reversionSegments,
|
|
6866
|
+
revertCursor: currentProjection?.revertCursor ?? legacyProjection?.revertCursor ?? 0,
|
|
6867
|
+
excludedRunIds: cloneSerializable(
|
|
6868
|
+
currentProjection?.excludedRunIds ?? legacyProjection?.excludedRunIds ?? [],
|
|
6869
|
+
),
|
|
6870
|
+
...(pendingReversion === undefined
|
|
6871
|
+
? {}
|
|
6872
|
+
: { pendingReversion: cloneSerializable(pendingReversion) }),
|
|
6873
|
+
pendingReversionReleases,
|
|
6874
|
+
projectionSchemaVersion: projection?.schemaVersion ?? 3,
|
|
6875
|
+
projectionReconciliationRequired: false,
|
|
6876
|
+
...(projection && projection.schemaVersion !== 3
|
|
6877
|
+
? { projectionBaseline: cloneSerializable(projection) }
|
|
6878
|
+
: {}),
|
|
4261
6879
|
projectionRevision: projection?.revision ?? 0,
|
|
4262
6880
|
projectionQueue: Promise.resolve(),
|
|
4263
6881
|
rememberedPermissionKeys: new Set(permission?.rememberedPermissionKeys ?? []),
|
|
@@ -4306,7 +6924,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4306
6924
|
eventStore,
|
|
4307
6925
|
executionContext: contextualExecutionContext,
|
|
4308
6926
|
contextBuilder: ({ events }) => hydrateAgentMessages(
|
|
4309
|
-
(this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
|
|
6927
|
+
(this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
|
|
6928
|
+
events: this.filterReversionContextEvents(stored, events),
|
|
6929
|
+
}),
|
|
4310
6930
|
),
|
|
4311
6931
|
...(contextCompactor
|
|
4312
6932
|
? {
|
|
@@ -4323,7 +6943,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4323
6943
|
) {
|
|
4324
6944
|
throw new Error('Agent context compaction is missing its exact FlexHarness invocation context.');
|
|
4325
6945
|
}
|
|
4326
|
-
|
|
6946
|
+
const filteredEvents = this.filterReversionContextEvents(stored, events);
|
|
6947
|
+
const filteredMessages = hydrateAgentMessages(
|
|
6948
|
+
(this.agentSessionPolicy.contextBuilder
|
|
6949
|
+
?? ((contextOptions) => plugins.buildModelMessages(contextOptions.events)))({
|
|
6950
|
+
events: filteredEvents,
|
|
6951
|
+
}),
|
|
6952
|
+
);
|
|
6953
|
+
return contextCompactor(filteredMessages, filteredEvents, {
|
|
4327
6954
|
...options,
|
|
4328
6955
|
...invocationContext,
|
|
4329
6956
|
scope: invocationContext.scope as TScope,
|
|
@@ -4390,13 +7017,62 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4390
7017
|
private async repairLoadedSession(
|
|
4391
7018
|
storageKey: string,
|
|
4392
7019
|
stored: IStoredSessionState,
|
|
7020
|
+
state?: IStorageState,
|
|
7021
|
+
scopeId?: string,
|
|
7022
|
+
scope?: TScope,
|
|
4393
7023
|
): Promise<boolean> {
|
|
4394
7024
|
const beforeProjection = JSON.stringify({
|
|
4395
7025
|
messages: stored.messages,
|
|
4396
7026
|
stagedTerminals: stored.stagedTerminals,
|
|
7027
|
+
reversionSegments: stored.reversionSegments,
|
|
7028
|
+
revertCursor: stored.revertCursor,
|
|
7029
|
+
excludedRunIds: stored.excludedRunIds,
|
|
7030
|
+
pendingReversion: stored.pendingReversion,
|
|
7031
|
+
pendingReversionReleases: stored.pendingReversionReleases,
|
|
4397
7032
|
});
|
|
4398
7033
|
const beforeSession = JSON.stringify(stored.session);
|
|
7034
|
+
if (
|
|
7035
|
+
!this.turnReversionProvider
|
|
7036
|
+
&& (
|
|
7037
|
+
stored.reversionSegments.some((segment) => segment.captureId)
|
|
7038
|
+
|| stored.pendingReversionReleases.length > 0
|
|
7039
|
+
|| stored.pendingReversion?.kind === 'capture'
|
|
7040
|
+
)
|
|
7041
|
+
) {
|
|
7042
|
+
throw new FlexHarnessValidationError(
|
|
7043
|
+
'Capture-backed session recovery requires its turn reversion provider.',
|
|
7044
|
+
);
|
|
7045
|
+
}
|
|
7046
|
+
const requiredProtocols = new Set<number>([
|
|
7047
|
+
...stored.reversionSegments
|
|
7048
|
+
.filter((segment) => segment.captureId !== undefined)
|
|
7049
|
+
.map((segment) => segment.protocolVersion),
|
|
7050
|
+
...stored.pendingReversionReleases.map((release) => release.protocolVersion),
|
|
7051
|
+
...(stored.pendingReversion?.kind === 'capture'
|
|
7052
|
+
? [stored.pendingReversion.protocolVersion]
|
|
7053
|
+
: []),
|
|
7054
|
+
]);
|
|
7055
|
+
const providerProtocol = this.reversionProviderProtocolVersion();
|
|
7056
|
+
if (requiredProtocols.size > 0 && (
|
|
7057
|
+
requiredProtocols.size !== 1
|
|
7058
|
+
|| !requiredProtocols.has(providerProtocol ?? 0)
|
|
7059
|
+
)) {
|
|
7060
|
+
throw new FlexHarnessValidationError(
|
|
7061
|
+
`Capture-backed session recovery requires protocolVersion ${[...requiredProtocols].join(', ')}.`,
|
|
7062
|
+
);
|
|
7063
|
+
}
|
|
7064
|
+
if (stored.pendingReversion?.kind === 'capture' && stored.pendingReversionReleases.length > 0) {
|
|
7065
|
+
if (!state || scopeId === undefined || scope === undefined) {
|
|
7066
|
+
throw new FlexHarnessValidationError('Pending capture recovery is missing its scope context.');
|
|
7067
|
+
}
|
|
7068
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
7069
|
+
this.assertStateAcceptingWork(state);
|
|
7070
|
+
}
|
|
4399
7071
|
const outcomes = this.canonicalOutcomes(stored.agentSession.getEvents());
|
|
7072
|
+
const firstHiddenCandidate = this.completedReversionCandidates(stored)[stored.revertCursor];
|
|
7073
|
+
const reversionVisibilityBoundary = firstHiddenCandidate
|
|
7074
|
+
? stored.reversionSegments.findIndex((segment) => segment.runId === firstHiddenCandidate.runId)
|
|
7075
|
+
: stored.reversionSegments.length;
|
|
4400
7076
|
const stages = new Map(stored.stagedTerminals.map((terminal) => [terminal.runId, terminal]));
|
|
4401
7077
|
const runIds = new Set([
|
|
4402
7078
|
...stored.messages.map((message) => message.runId),
|
|
@@ -4433,6 +7109,37 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4433
7109
|
}
|
|
4434
7110
|
stored.stagedTerminals = stored.stagedTerminals.filter((terminal) => !outcomes.has(terminal.runId));
|
|
4435
7111
|
if (stored.stagedTerminals.length > 0) stored.stagedTerminals = [];
|
|
7112
|
+
const activeEvents = stored.agentSession.getEvents();
|
|
7113
|
+
const activeEventIds = new Set(activeEvents.map((event) => event.id));
|
|
7114
|
+
for (let index = 0; index < stored.reversionSegments.length; index++) {
|
|
7115
|
+
const segment = stored.reversionSegments[index];
|
|
7116
|
+
if (segment.eventIds.length > 0 && !segment.eventIds.some((id) => activeEventIds.has(id))) {
|
|
7117
|
+
segment.contextAvailable = false;
|
|
7118
|
+
}
|
|
7119
|
+
if (segment.status !== 'capturing') {
|
|
7120
|
+
const outcome = outcomes.get(segment.runId);
|
|
7121
|
+
if (outcome === 'accepted') segment.status = 'completed';
|
|
7122
|
+
else if (outcome === 'rejected') segment.status = 'failed';
|
|
7123
|
+
else if (outcome === 'interrupted' || segment.contextAvailable) segment.status = 'cancelled';
|
|
7124
|
+
}
|
|
7125
|
+
}
|
|
7126
|
+
stored.revertCursor = stored.reversionSegments
|
|
7127
|
+
.slice(0, reversionVisibilityBoundary)
|
|
7128
|
+
.filter((segment) => segment.status === 'completed')
|
|
7129
|
+
.length;
|
|
7130
|
+
this.commitArchivedHiddenBranchState(stored);
|
|
7131
|
+
this.pruneReversionState(stored, false, true);
|
|
7132
|
+
if (stored.pendingReversion?.kind === 'apply') {
|
|
7133
|
+
if (!state || scopeId === undefined || scope === undefined) {
|
|
7134
|
+
throw new FlexHarnessValidationError('Pending reversion recovery is missing its scope context.');
|
|
7135
|
+
}
|
|
7136
|
+
await this.resumePendingApply(state, stored, scopeId, scope);
|
|
7137
|
+
} else if (stored.pendingReversion?.kind === 'capture') {
|
|
7138
|
+
if (!this.turnReversionProvider || !state || scopeId === undefined || scope === undefined) {
|
|
7139
|
+
throw new FlexHarnessValidationError('Pending capture recovery requires its reversion provider.');
|
|
7140
|
+
}
|
|
7141
|
+
await this.recoverPendingCapture(state, stored, scopeId, scope, outcomes);
|
|
7142
|
+
}
|
|
4436
7143
|
if (latestTerminal) {
|
|
4437
7144
|
const completedAt = latestTerminal.assistantMessage.completedAt ?? new Date().toISOString();
|
|
4438
7145
|
stored.session.status = latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status;
|
|
@@ -4459,6 +7166,11 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4459
7166
|
const projectionChanged = beforeProjection !== JSON.stringify({
|
|
4460
7167
|
messages: stored.messages,
|
|
4461
7168
|
stagedTerminals: stored.stagedTerminals,
|
|
7169
|
+
reversionSegments: stored.reversionSegments,
|
|
7170
|
+
revertCursor: stored.revertCursor,
|
|
7171
|
+
excludedRunIds: stored.excludedRunIds,
|
|
7172
|
+
pendingReversion: stored.pendingReversion,
|
|
7173
|
+
pendingReversionReleases: stored.pendingReversionReleases,
|
|
4462
7174
|
});
|
|
4463
7175
|
if (projectionChanged) {
|
|
4464
7176
|
const expected = stored.projectionRevision;
|
|
@@ -4469,6 +7181,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4469
7181
|
snapshot,
|
|
4470
7182
|
expected,
|
|
4471
7183
|
);
|
|
7184
|
+
stored.projectionSchemaVersion = 3;
|
|
7185
|
+
delete stored.projectionBaseline;
|
|
7186
|
+
stored.projectionReconciliationRequired = false;
|
|
4472
7187
|
stored.projectionRevision = snapshot.revision;
|
|
4473
7188
|
}
|
|
4474
7189
|
return beforeSession !== JSON.stringify(stored.session);
|
|
@@ -4628,18 +7343,47 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4628
7343
|
): Promise<TValue> {
|
|
4629
7344
|
const operation = stored.projectionQueue.then(async () => {
|
|
4630
7345
|
const beforeRevision = stored.projectionRevision;
|
|
7346
|
+
const beforeSchemaVersion = stored.projectionSchemaVersion;
|
|
7347
|
+
const beforeReconciliationRequired = stored.projectionReconciliationRequired;
|
|
7348
|
+
const beforeBaseline = stored.projectionBaseline === undefined
|
|
7349
|
+
? undefined
|
|
7350
|
+
: cloneSerializable(stored.projectionBaseline);
|
|
4631
7351
|
const beforeMessages = cloneSerializable(stored.messages);
|
|
4632
7352
|
const beforeStages = cloneSerializable(stored.stagedTerminals);
|
|
4633
|
-
const
|
|
4634
|
-
|
|
7353
|
+
const beforeSegments = cloneSerializable(stored.reversionSegments);
|
|
7354
|
+
const beforeCursor = stored.revertCursor;
|
|
7355
|
+
const beforeExcludedRunIds = cloneSerializable(stored.excludedRunIds);
|
|
7356
|
+
const beforePendingReversion = stored.pendingReversion === undefined
|
|
7357
|
+
? undefined
|
|
7358
|
+
: cloneSerializable(stored.pendingReversion);
|
|
7359
|
+
const beforePendingReversionReleases = cloneSerializable(stored.pendingReversionReleases);
|
|
7360
|
+
const beforeSnapshot: TFlexProjectionSnapshot = beforeBaseline ?? {
|
|
7361
|
+
schemaVersion: 3,
|
|
4635
7362
|
revision: beforeRevision,
|
|
4636
7363
|
messages: cloneSerializable(beforeMessages),
|
|
4637
7364
|
stagedTerminals: cloneSerializable(beforeStages),
|
|
7365
|
+
reversionSegments: cloneSerializable(beforeSegments),
|
|
7366
|
+
revertCursor: beforeCursor,
|
|
7367
|
+
excludedRunIds: cloneSerializable(beforeExcludedRunIds),
|
|
7368
|
+
...(beforePendingReversion === undefined
|
|
7369
|
+
? {}
|
|
7370
|
+
: { pendingReversion: cloneSerializable(beforePendingReversion) }),
|
|
7371
|
+
pendingReversionReleases: cloneSerializable(beforePendingReversionReleases),
|
|
4638
7372
|
};
|
|
4639
7373
|
const restore = () => {
|
|
7374
|
+
stored.projectionSchemaVersion = beforeSchemaVersion;
|
|
7375
|
+
stored.projectionReconciliationRequired = beforeReconciliationRequired;
|
|
7376
|
+
if (beforeBaseline === undefined) delete stored.projectionBaseline;
|
|
7377
|
+
else stored.projectionBaseline = beforeBaseline;
|
|
4640
7378
|
stored.projectionRevision = beforeRevision;
|
|
4641
7379
|
stored.messages = beforeMessages;
|
|
4642
7380
|
stored.stagedTerminals = beforeStages;
|
|
7381
|
+
stored.reversionSegments = beforeSegments;
|
|
7382
|
+
stored.revertCursor = beforeCursor;
|
|
7383
|
+
stored.excludedRunIds = beforeExcludedRunIds;
|
|
7384
|
+
if (beforePendingReversion === undefined) delete stored.pendingReversion;
|
|
7385
|
+
else stored.pendingReversion = beforePendingReversion;
|
|
7386
|
+
stored.pendingReversionReleases = beforePendingReversionReleases;
|
|
4643
7387
|
};
|
|
4644
7388
|
let result: TValue;
|
|
4645
7389
|
try {
|
|
@@ -4656,6 +7400,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4656
7400
|
snapshot,
|
|
4657
7401
|
beforeRevision,
|
|
4658
7402
|
);
|
|
7403
|
+
stored.projectionSchemaVersion = 3;
|
|
7404
|
+
delete stored.projectionBaseline;
|
|
7405
|
+
stored.projectionReconciliationRequired = false;
|
|
4659
7406
|
stored.projectionRevision = snapshot.revision;
|
|
4660
7407
|
return result;
|
|
4661
7408
|
} catch (error) {
|
|
@@ -4664,11 +7411,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4664
7411
|
throw error;
|
|
4665
7412
|
}
|
|
4666
7413
|
if (error instanceof FlexHarnessStoreCommitUncertainError) {
|
|
7414
|
+
stored.projectionSchemaVersion = 3;
|
|
7415
|
+
stored.projectionReconciliationRequired = true;
|
|
4667
7416
|
stored.projectionRevision = snapshot.revision;
|
|
4668
7417
|
state.lifecycle = 'fenced';
|
|
4669
7418
|
throw error;
|
|
4670
7419
|
}
|
|
4671
|
-
let current:
|
|
7420
|
+
let current: TFlexProjectionSnapshot | undefined;
|
|
4672
7421
|
let reconciliationError: unknown;
|
|
4673
7422
|
try {
|
|
4674
7423
|
current = await this.stores.projections.load(
|
|
@@ -4679,6 +7428,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4679
7428
|
reconciliationError = loadError;
|
|
4680
7429
|
}
|
|
4681
7430
|
if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
|
|
7431
|
+
stored.projectionSchemaVersion = 3;
|
|
7432
|
+
delete stored.projectionBaseline;
|
|
7433
|
+
stored.projectionReconciliationRequired = false;
|
|
4682
7434
|
stored.projectionRevision = snapshot.revision;
|
|
4683
7435
|
throw error;
|
|
4684
7436
|
}
|
|
@@ -4689,6 +7441,8 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4689
7441
|
restore();
|
|
4690
7442
|
throw error;
|
|
4691
7443
|
}
|
|
7444
|
+
stored.projectionSchemaVersion = 3;
|
|
7445
|
+
stored.projectionReconciliationRequired = true;
|
|
4692
7446
|
stored.projectionRevision = snapshot.revision;
|
|
4693
7447
|
state.lifecycle = 'fenced';
|
|
4694
7448
|
throw reconciliationError === undefined
|
|
@@ -4797,15 +7551,92 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4797
7551
|
private createProjectionSnapshot(
|
|
4798
7552
|
stored: IStoredSessionState,
|
|
4799
7553
|
revision: number,
|
|
4800
|
-
):
|
|
7554
|
+
): IFlexProjectionSnapshotCurrent {
|
|
4801
7555
|
return {
|
|
4802
|
-
schemaVersion:
|
|
7556
|
+
schemaVersion: 3,
|
|
4803
7557
|
revision,
|
|
4804
7558
|
messages: cloneSerializable(stored.messages),
|
|
4805
7559
|
stagedTerminals: cloneSerializable(stored.stagedTerminals),
|
|
7560
|
+
reversionSegments: cloneSerializable(stored.reversionSegments),
|
|
7561
|
+
revertCursor: stored.revertCursor,
|
|
7562
|
+
excludedRunIds: cloneSerializable(stored.excludedRunIds),
|
|
7563
|
+
...(stored.pendingReversion === undefined
|
|
7564
|
+
? {}
|
|
7565
|
+
: { pendingReversion: cloneSerializable(stored.pendingReversion) }),
|
|
7566
|
+
pendingReversionReleases: cloneSerializable(stored.pendingReversionReleases),
|
|
7567
|
+
};
|
|
7568
|
+
}
|
|
7569
|
+
|
|
7570
|
+
private upgradeProjectionSnapshot(
|
|
7571
|
+
projection: TFlexProjectionSnapshot,
|
|
7572
|
+
): IFlexProjectionSnapshotCurrent {
|
|
7573
|
+
if (projection.schemaVersion === 3) return cloneSerializable(projection);
|
|
7574
|
+
if (projection.schemaVersion === 1) {
|
|
7575
|
+
return {
|
|
7576
|
+
schemaVersion: 3,
|
|
7577
|
+
revision: projection.revision,
|
|
7578
|
+
messages: cloneSerializable(projection.messages),
|
|
7579
|
+
stagedTerminals: cloneSerializable(projection.stagedTerminals),
|
|
7580
|
+
reversionSegments: [],
|
|
7581
|
+
revertCursor: 0,
|
|
7582
|
+
excludedRunIds: [],
|
|
7583
|
+
pendingReversionReleases: [],
|
|
7584
|
+
};
|
|
7585
|
+
}
|
|
7586
|
+
return {
|
|
7587
|
+
schemaVersion: 3,
|
|
7588
|
+
revision: projection.revision,
|
|
7589
|
+
messages: cloneSerializable(projection.messages),
|
|
7590
|
+
stagedTerminals: cloneSerializable(projection.stagedTerminals),
|
|
7591
|
+
reversionSegments: projection.reversionSegments.map((segment) => ({
|
|
7592
|
+
...cloneSerializable(segment),
|
|
7593
|
+
protocolVersion: 1,
|
|
7594
|
+
provenance: segment.workspaceCaptured ? 'workspace' as const : 'transcript' as const,
|
|
7595
|
+
...(segment.workspaceCaptured
|
|
7596
|
+
? { disposition: segment.status === 'capturing' ? 'pending' as const : 'revertible' as const }
|
|
7597
|
+
: {}),
|
|
7598
|
+
})),
|
|
7599
|
+
revertCursor: projection.revertCursor,
|
|
7600
|
+
excludedRunIds: cloneSerializable(projection.excludedRunIds),
|
|
7601
|
+
...(projection.pendingReversion === undefined
|
|
7602
|
+
? {}
|
|
7603
|
+
: {
|
|
7604
|
+
pendingReversion: projection.pendingReversion.kind === 'capture'
|
|
7605
|
+
? { ...cloneSerializable(projection.pendingReversion), protocolVersion: 1 }
|
|
7606
|
+
: cloneSerializable(projection.pendingReversion),
|
|
7607
|
+
}),
|
|
7608
|
+
pendingReversionReleases: projection.pendingReversionReleases.map((release) => ({
|
|
7609
|
+
...cloneSerializable(release),
|
|
7610
|
+
protocolVersion: 1,
|
|
7611
|
+
})),
|
|
4806
7612
|
};
|
|
4807
7613
|
}
|
|
4808
7614
|
|
|
7615
|
+
private async reconcileProjectionFromStore(stored: IStoredSessionState): Promise<void> {
|
|
7616
|
+
if (!stored.projectionReconciliationRequired) return;
|
|
7617
|
+
const projection = await this.stores.projections.load(
|
|
7618
|
+
stored.storageKey,
|
|
7619
|
+
stored.session.sessionId,
|
|
7620
|
+
);
|
|
7621
|
+
if (!projection) {
|
|
7622
|
+
throw new FlexHarnessValidationError('Uncertain projection persistence could not be reloaded.');
|
|
7623
|
+
}
|
|
7624
|
+
const current = this.upgradeProjectionSnapshot(projection);
|
|
7625
|
+
stored.messages = current.messages;
|
|
7626
|
+
stored.stagedTerminals = current.stagedTerminals;
|
|
7627
|
+
stored.reversionSegments = current.reversionSegments;
|
|
7628
|
+
stored.revertCursor = current.revertCursor;
|
|
7629
|
+
stored.excludedRunIds = current.excludedRunIds;
|
|
7630
|
+
if (current.pendingReversion === undefined) delete stored.pendingReversion;
|
|
7631
|
+
else stored.pendingReversion = current.pendingReversion;
|
|
7632
|
+
stored.pendingReversionReleases = current.pendingReversionReleases;
|
|
7633
|
+
if (projection.schemaVersion === 3) delete stored.projectionBaseline;
|
|
7634
|
+
else stored.projectionBaseline = cloneSerializable(projection);
|
|
7635
|
+
stored.projectionSchemaVersion = projection.schemaVersion;
|
|
7636
|
+
stored.projectionRevision = projection.revision;
|
|
7637
|
+
stored.projectionReconciliationRequired = false;
|
|
7638
|
+
}
|
|
7639
|
+
|
|
4809
7640
|
private async cleanupSessionDomains(storageKey: string, sessionId: string): Promise<void> {
|
|
4810
7641
|
const results = await Promise.allSettled([
|
|
4811
7642
|
this.stores.agentEvents.deleteSession(storageKey, sessionId),
|
|
@@ -4914,15 +7745,19 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4914
7745
|
}
|
|
4915
7746
|
const group = this.tombstoneGroup(state, rootSessionId);
|
|
4916
7747
|
if (group.length === 0) return;
|
|
7748
|
+
const exactInvocation = invocation ?? {
|
|
7749
|
+
scopeId: state.scopeContext.scopeId,
|
|
7750
|
+
scope: state.scopeContext.scope as TScope,
|
|
7751
|
+
};
|
|
4917
7752
|
const groupIds = new Set(group.map((tombstone) => tombstone.sessionId));
|
|
4918
7753
|
const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
|
|
4919
7754
|
const contextFor = (
|
|
4920
7755
|
sessionId: string,
|
|
4921
7756
|
retained?: IRetainedSessionCleanup,
|
|
4922
|
-
): IFlexAgentContextInvocation<TScope> | undefined =>
|
|
7757
|
+
): IFlexAgentContextInvocation<TScope> | undefined => exactInvocation
|
|
4923
7758
|
? this.createCompactorContext(
|
|
4924
|
-
|
|
4925
|
-
|
|
7759
|
+
exactInvocation.scopeId,
|
|
7760
|
+
exactInvocation.scope,
|
|
4926
7761
|
state.storageKey,
|
|
4927
7762
|
sessionId,
|
|
4928
7763
|
)
|
|
@@ -4995,6 +7830,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4995
7830
|
if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
|
|
4996
7831
|
throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
|
|
4997
7832
|
}
|
|
7833
|
+
const releaseContext = exactInvocation ?? retained?.stored.compactorContext as
|
|
7834
|
+
IFlexAgentContextInvocation<TScope> | undefined;
|
|
7835
|
+
if (!releaseContext) {
|
|
7836
|
+
throw new FlexHarnessValidationError(
|
|
7837
|
+
'Tombstone cleanup requires its exact scope context before deleting projection data.',
|
|
7838
|
+
);
|
|
7839
|
+
}
|
|
7840
|
+
await this.releaseDeletedSessionReversions(
|
|
7841
|
+
state,
|
|
7842
|
+
state.storageKey,
|
|
7843
|
+
sessionId,
|
|
7844
|
+
releaseContext.scopeId,
|
|
7845
|
+
releaseContext.scope,
|
|
7846
|
+
retained?.stored,
|
|
7847
|
+
);
|
|
4998
7848
|
await this.cleanupSessionDomains(state.storageKey, sessionId);
|
|
4999
7849
|
if (retained) retained.domainsCompleted = true;
|
|
5000
7850
|
}
|
|
@@ -5182,6 +8032,26 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5182
8032
|
void drain.catch(() => undefined);
|
|
5183
8033
|
}
|
|
5184
8034
|
|
|
8035
|
+
private deferReversionReleaseDrain(
|
|
8036
|
+
state: IStorageState,
|
|
8037
|
+
stored: IStoredSessionState,
|
|
8038
|
+
scopeId: string,
|
|
8039
|
+
scope: TScope,
|
|
8040
|
+
): void {
|
|
8041
|
+
state.lifecycle = 'fenced';
|
|
8042
|
+
const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
|
|
8043
|
+
this.abortCompactorLifecycle(stored, reason);
|
|
8044
|
+
const stateLoad = this.stateLoads.get(state.storageKey);
|
|
8045
|
+
if (!stateLoad) return;
|
|
8046
|
+
const drain = this.drainStorage(
|
|
8047
|
+
state.storageKey,
|
|
8048
|
+
stateLoad,
|
|
8049
|
+
reason,
|
|
8050
|
+
{ scopeId, scope },
|
|
8051
|
+
);
|
|
8052
|
+
void drain.catch(() => undefined);
|
|
8053
|
+
}
|
|
8054
|
+
|
|
5185
8055
|
private sessionsAreDependencyRelated(
|
|
5186
8056
|
state: IStorageState,
|
|
5187
8057
|
leftSessionId: string,
|
|
@@ -5387,6 +8257,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5387
8257
|
|
|
5388
8258
|
private async closeOrphanedResourcesInternal(storageKey?: string): Promise<unknown[]> {
|
|
5389
8259
|
const errors: unknown[] = [];
|
|
8260
|
+
for (const [key, owner] of [...this.orphanedTombstoneOwners]) {
|
|
8261
|
+
if (storageKey !== undefined && owner.state.storageKey !== storageKey) continue;
|
|
8262
|
+
try {
|
|
8263
|
+
await this.finishTombstoneCleanup(
|
|
8264
|
+
owner.state,
|
|
8265
|
+
owner.rootSessionId,
|
|
8266
|
+
owner.scopeId,
|
|
8267
|
+
{ scopeId: owner.scopeId, scope: owner.scope },
|
|
8268
|
+
);
|
|
8269
|
+
this.orphanedTombstoneOwners.delete(key);
|
|
8270
|
+
} catch (error) {
|
|
8271
|
+
errors.push(error);
|
|
8272
|
+
}
|
|
8273
|
+
}
|
|
5390
8274
|
const tombstoneCleanups = [...this.orphanedTombstoneCleanups.values()]
|
|
5391
8275
|
.filter((retained) => storageKey === undefined || retained.storageKey === storageKey);
|
|
5392
8276
|
const tombstoneResults = await Promise.allSettled(
|
|
@@ -5436,6 +8320,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5436
8320
|
...[...this.orphanedExecutionContexts.values()].map((owner) => owner.storageKey),
|
|
5437
8321
|
...[...this.orphanedProviderReleases.values()].map((retained) => retained.storageKey),
|
|
5438
8322
|
...[...this.orphanedTombstoneCleanups.values()].map((retained) => retained.storageKey),
|
|
8323
|
+
...[...this.orphanedTombstoneOwners.values()].map((owner) => owner.state.storageKey),
|
|
5439
8324
|
]);
|
|
5440
8325
|
}
|
|
5441
8326
|
|
|
@@ -5443,7 +8328,18 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5443
8328
|
const scope = await this.scopeResolver.resolveScope(scopeId);
|
|
5444
8329
|
this.assertOpen();
|
|
5445
8330
|
validateIdentifier(scope.storageKey, 'resolved storageKey');
|
|
8331
|
+
this.assertNoAmbientSlashCommandTeardown({ storageKey: scope.storageKey });
|
|
5446
8332
|
const errors: unknown[] = [];
|
|
8333
|
+
const listingSettlement = this.abortSlashCommandListings(
|
|
8334
|
+
scope.storageKey,
|
|
8335
|
+
this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
|
|
8336
|
+
);
|
|
8337
|
+
if (listingSettlement) await listingSettlement;
|
|
8338
|
+
await this.abortSlashCommandExecutions(
|
|
8339
|
+
scope.storageKey,
|
|
8340
|
+
this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
|
|
8341
|
+
errors,
|
|
8342
|
+
);
|
|
5447
8343
|
const stateLoad = this.stateLoads.get(scope.storageKey);
|
|
5448
8344
|
if (!stateLoad) {
|
|
5449
8345
|
const drain = this.storageDrains.get(scope.storageKey);
|
|
@@ -5515,8 +8411,11 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5515
8411
|
return;
|
|
5516
8412
|
}
|
|
5517
8413
|
if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
|
|
5518
|
-
const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
|
|
5519
8414
|
const errors: unknown[] = [];
|
|
8415
|
+
const listingSettlement = this.abortSlashCommandListings(storageKey, reason);
|
|
8416
|
+
if (listingSettlement) await listingSettlement;
|
|
8417
|
+
await this.abortSlashCommandExecutions(storageKey, reason, errors);
|
|
8418
|
+
const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
|
|
5520
8419
|
const contextFor = (sessionId: string) => invocation
|
|
5521
8420
|
? this.createCompactorContext(invocation.scopeId, invocation.scope, storageKey, sessionId)
|
|
5522
8421
|
: undefined;
|
|
@@ -5585,6 +8484,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5585
8484
|
'lifecycle-close',
|
|
5586
8485
|
));
|
|
5587
8486
|
}
|
|
8487
|
+
const releaseContext = invocation ?? stored.compactorContext as
|
|
8488
|
+
IFlexAgentContextInvocation<TScope> | undefined;
|
|
8489
|
+
if (releaseContext) {
|
|
8490
|
+
try {
|
|
8491
|
+
await this.drainReversionReleases(
|
|
8492
|
+
state,
|
|
8493
|
+
stored,
|
|
8494
|
+
releaseContext.scopeId,
|
|
8495
|
+
releaseContext.scope,
|
|
8496
|
+
true,
|
|
8497
|
+
);
|
|
8498
|
+
} catch (error) {
|
|
8499
|
+
errors.push(error);
|
|
8500
|
+
}
|
|
8501
|
+
}
|
|
5588
8502
|
this.purgeStoredPromptQueue(stored);
|
|
5589
8503
|
}
|
|
5590
8504
|
const roots = this.orderTombstoneRootsChildFirst(
|
|
@@ -5629,6 +8543,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5629
8543
|
if (!pending.controller.signal.aborted) pending.controller.abort(pendingAdmissionReason);
|
|
5630
8544
|
}
|
|
5631
8545
|
await Promise.all(pendingAdmissions.map((pending) => pending.settled));
|
|
8546
|
+
const errors: unknown[] = [];
|
|
8547
|
+
const listingSettlement = this.abortSlashCommandListings(
|
|
8548
|
+
undefined,
|
|
8549
|
+
this.trustInternalError(new FlexHarnessAbortError(
|
|
8550
|
+
'The slash command listing was aborted because FlexHarness was disposed.',
|
|
8551
|
+
)),
|
|
8552
|
+
);
|
|
8553
|
+
if (listingSettlement) await listingSettlement;
|
|
8554
|
+
await this.abortSlashCommandExecutions(
|
|
8555
|
+
undefined,
|
|
8556
|
+
this.trustInternalError(new FlexHarnessAbortError(
|
|
8557
|
+
'The slash command was aborted because FlexHarness was disposed.',
|
|
8558
|
+
)),
|
|
8559
|
+
errors,
|
|
8560
|
+
);
|
|
5632
8561
|
const loads = [...this.stateLoads.entries()];
|
|
5633
8562
|
const results = await Promise.allSettled(loads.map(([storageKey, stateLoad]) =>
|
|
5634
8563
|
this.drainStorage(
|
|
@@ -5638,7 +8567,6 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5638
8567
|
'The run was aborted because FlexHarness was disposed.',
|
|
5639
8568
|
)),
|
|
5640
8569
|
)));
|
|
5641
|
-
const errors: unknown[] = [];
|
|
5642
8570
|
for (const result of results) {
|
|
5643
8571
|
if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
|
|
5644
8572
|
}
|
|
@@ -5651,6 +8579,8 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5651
8579
|
this.listeners.clear();
|
|
5652
8580
|
if (errors.length > 0) throw combineErrors(errors);
|
|
5653
8581
|
this.compactorInvocationContext.disable();
|
|
8582
|
+
this.slashCommandInvocationContext.disable();
|
|
8583
|
+
this.slashCommandActivityContext.disable();
|
|
5654
8584
|
this.stateLoads.clear();
|
|
5655
8585
|
this.scopeAdmissions.clear();
|
|
5656
8586
|
this.scopeRetirements.clear();
|
|
@@ -5658,6 +8588,45 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5658
8588
|
this.storageCompactorLifecycleControllers.clear();
|
|
5659
8589
|
}
|
|
5660
8590
|
|
|
8591
|
+
private async abortSlashCommandExecutions(
|
|
8592
|
+
storageKey: string | undefined,
|
|
8593
|
+
reason: FlexHarnessAbortError,
|
|
8594
|
+
errors: unknown[],
|
|
8595
|
+
sessionId?: string,
|
|
8596
|
+
): Promise<void> {
|
|
8597
|
+
const active = [...this.activeSlashCommandExecutions.values()]
|
|
8598
|
+
.filter((execution) =>
|
|
8599
|
+
(storageKey === undefined || execution.storageKey === storageKey)
|
|
8600
|
+
&& (sessionId === undefined || execution.sessionId === sessionId));
|
|
8601
|
+
for (const execution of active) {
|
|
8602
|
+
if (!execution.controller.signal.aborted) execution.controller.abort(reason);
|
|
8603
|
+
}
|
|
8604
|
+
const awaited = active.filter((execution) => execution.kind !== 'prompt-admission');
|
|
8605
|
+
const results = await Promise.allSettled(awaited.map((execution) => execution.completion));
|
|
8606
|
+
for (let index = 0; index < results.length; index++) {
|
|
8607
|
+
const result = results[index];
|
|
8608
|
+
if (result.status === 'rejected' && awaited[index].kind === 'handler') {
|
|
8609
|
+
this.appendUnexpectedErrors(errors, result.reason);
|
|
8610
|
+
}
|
|
8611
|
+
}
|
|
8612
|
+
}
|
|
8613
|
+
|
|
8614
|
+
private abortSlashCommandListings(
|
|
8615
|
+
storageKey: string | undefined,
|
|
8616
|
+
reason: FlexHarnessAbortError,
|
|
8617
|
+
sessionId?: string,
|
|
8618
|
+
): Promise<void> | undefined {
|
|
8619
|
+
const active = [...this.activeSlashCommandListings]
|
|
8620
|
+
.filter((listing) =>
|
|
8621
|
+
(storageKey === undefined || listing.storageKey === storageKey)
|
|
8622
|
+
&& (sessionId === undefined || listing.sessionId === sessionId));
|
|
8623
|
+
if (active.length === 0) return undefined;
|
|
8624
|
+
for (const listing of active) {
|
|
8625
|
+
if (!listing.controller.signal.aborted) listing.controller.abort(reason);
|
|
8626
|
+
}
|
|
8627
|
+
return Promise.allSettled(active.map((listing) => listing.completion)).then(() => undefined);
|
|
8628
|
+
}
|
|
8629
|
+
|
|
5661
8630
|
private appendUnexpectedErrors(target: unknown[], error: unknown): void {
|
|
5662
8631
|
if (error instanceof FlexHarnessRunError) {
|
|
5663
8632
|
for (const nested of error.errors) this.appendUnexpectedErrors(target, nested);
|
|
@@ -5722,6 +8691,23 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5722
8691
|
}
|
|
5723
8692
|
const state = await stateLoad;
|
|
5724
8693
|
this.assertOpen();
|
|
8694
|
+
if (state.lifecycle === 'fenced') {
|
|
8695
|
+
const drain = this.storageDrains.get(scope.storageKey) ?? this.drainStorage(
|
|
8696
|
+
scope.storageKey,
|
|
8697
|
+
stateLoad,
|
|
8698
|
+
this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.')),
|
|
8699
|
+
{ scopeId, scope: scope.scope },
|
|
8700
|
+
);
|
|
8701
|
+
if (this.ambientSlashCommandOwner({ storageKey: scope.storageKey })) {
|
|
8702
|
+
throw this.createScopeRetirementError();
|
|
8703
|
+
}
|
|
8704
|
+
try {
|
|
8705
|
+
await drain;
|
|
8706
|
+
} catch {
|
|
8707
|
+
// Fenced cleanup ownership remains available to explicit retirement or disposal.
|
|
8708
|
+
}
|
|
8709
|
+
throw this.createScopeRetirementError();
|
|
8710
|
+
}
|
|
5725
8711
|
if (
|
|
5726
8712
|
admission.retiring
|
|
5727
8713
|
|| admission.generation !== generation
|
|
@@ -5756,6 +8742,29 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5756
8742
|
return this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage));
|
|
5757
8743
|
}
|
|
5758
8744
|
|
|
8745
|
+
private ambientSlashCommandOwner(
|
|
8746
|
+
criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
|
|
8747
|
+
): ISlashCommandInvocationOwner | ISlashCommandActivityOwner | undefined {
|
|
8748
|
+
const matches = (owner: ISlashCommandInvocationOwner | ISlashCommandActivityOwner) => (
|
|
8749
|
+
(criteria.scopeId === undefined || owner.scopeId === criteria.scopeId)
|
|
8750
|
+
&& (criteria.storageKey === undefined || owner.storageKey === criteria.storageKey)
|
|
8751
|
+
&& (criteria.sessionId === undefined || owner.sessionId === criteria.sessionId)
|
|
8752
|
+
);
|
|
8753
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
8754
|
+
if (invocationOwner && matches(invocationOwner)) return invocationOwner;
|
|
8755
|
+
const activityOwner = this.slashCommandActivityContext.getStore();
|
|
8756
|
+
return activityOwner && matches(activityOwner) ? activityOwner : undefined;
|
|
8757
|
+
}
|
|
8758
|
+
|
|
8759
|
+
private assertNoAmbientSlashCommandTeardown(
|
|
8760
|
+
criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
|
|
8761
|
+
): void {
|
|
8762
|
+
const owner = this.ambientSlashCommandOwner(criteria);
|
|
8763
|
+
if (owner) {
|
|
8764
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(owner.sessionId));
|
|
8765
|
+
}
|
|
8766
|
+
}
|
|
8767
|
+
|
|
5759
8768
|
private assertStateAcceptingWork(state: IStorageState): void {
|
|
5760
8769
|
if (state.lifecycle !== 'active' || this.storageDrains.has(state.storageKey)) {
|
|
5761
8770
|
throw this.createScopeRetirementError();
|
|
@@ -5805,6 +8814,17 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5805
8814
|
return stored;
|
|
5806
8815
|
}
|
|
5807
8816
|
|
|
8817
|
+
private completedReversionCandidates(stored: IStoredSessionState): IFlexReversionSegment[] {
|
|
8818
|
+
return stored.reversionSegments.filter((segment) => segment.status === 'completed');
|
|
8819
|
+
}
|
|
8820
|
+
|
|
8821
|
+
private visibleMessages(stored: IStoredSessionState): IFlexMessage[] {
|
|
8822
|
+
const firstHiddenSegment = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
|
|
8823
|
+
if (!firstHiddenSegment) return stored.messages;
|
|
8824
|
+
const boundary = stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
|
|
8825
|
+
return boundary < 0 ? stored.messages : stored.messages.slice(0, boundary);
|
|
8826
|
+
}
|
|
8827
|
+
|
|
5808
8828
|
private requireMutableSession(state: IStorageState, sessionId: string): IStoredSessionState {
|
|
5809
8829
|
const stored = this.requireSession(state, sessionId);
|
|
5810
8830
|
const pending = [...state.pendingPermissions.values()].some((entry) =>
|
|
@@ -5819,7 +8839,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5819
8839
|
}
|
|
5820
8840
|
|
|
5821
8841
|
private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
|
|
5822
|
-
const message = stored.
|
|
8842
|
+
const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
|
|
5823
8843
|
if (!message) throw new FlexHarnessNotFoundError('Message', messageId);
|
|
5824
8844
|
return message;
|
|
5825
8845
|
}
|