@sublang/playbook 15.1.0 → 17.0.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/README.md +2 -0
- package/docs/cli.md +36 -18
- package/docs/configuration.md +7 -5
- package/docs/embedding.md +28 -7
- package/package.json +4 -4
- package/reference/sdlc/branch.playbook/branch.fsm.d.ts +63 -33
- package/reference/sdlc/branch.playbook/branch.fsm.js +190 -108
- package/reference/sdlc/branch.playbook/branch.fsm.ts +269 -147
- package/reference/sdlc/branch.playbook/branch.gears.md +7 -17
- package/reference/sdlc/branch.playbook/branch.playbook.d.ts +12 -16
- package/reference/sdlc/branch.playbook/branch.playbook.js +112 -101
- package/reference/sdlc/branch.playbook/branch.playbook.ts +128 -171
- package/reference/sdlc/branch.playbook/branch.registry.d.ts +3 -2
- package/reference/sdlc/branch.playbook/branch.registry.ts +6 -2
- package/reference/sdlc/captain.md +19 -10
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +6 -2
- package/reference/sdlc/captain.playbook/captain.fsm.js +19 -7
- package/reference/sdlc/captain.playbook/captain.fsm.ts +25 -10
- package/reference/sdlc/captain.playbook/captain.gears.md +16 -8
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +1 -1
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -2
- package/reference/sdlc/captain.playbook/captain.playbook.ts +4 -3
- package/reference/sdlc/code.playbook/bin/interactive-session.js +14 -1
- package/reference/sdlc/code.playbook/bin/portable-codec.js +3 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +1 -0
- package/reference/sdlc/code.playbook/bin/run.js +128 -85
- package/reference/sdlc/code.playbook/bin/session-host.js +40 -8
- package/reference/sdlc/code.playbook/bin/session-store.js +162 -34
- package/reference/sdlc/code.playbook/code.fsm.d.ts +146 -93
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +8 -8
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +13 -13
- package/reference/sdlc/code.playbook/code.fsm.js +609 -649
- package/reference/sdlc/code.playbook/code.fsm.ts +890 -821
- package/reference/sdlc/code.playbook/code.gears.md +39 -29
- package/reference/sdlc/code.playbook/code.playbook.d.ts +13 -16
- package/reference/sdlc/code.playbook/code.playbook.js +166 -147
- package/reference/sdlc/code.playbook/code.playbook.ts +180 -229
- package/reference/sdlc/code.playbook/code.registry.d.ts +3 -2
- package/reference/sdlc/code.playbook/code.registry.ts +6 -2
- package/reference/sdlc/code.playbook/host-capabilities.d.ts +3 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +74 -2
- package/reference/sdlc/code.playbook/playbook-captain.js +1548 -468
- package/reference/sdlc/code.playbook/playbook-captain.ts +1993 -573
- package/reference/sdlc/code.playbook/session-host.d.ts +4 -1
- package/reference/sdlc/code.playbook/session-store.d.ts +17 -0
- package/reference/sdlc/code.playbook/session-store.js +1 -0
- package/reference/sdlc/decide.md +1 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +273 -157
- package/reference/sdlc/decide.playbook/decide.fsm.js +765 -732
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1167 -945
- package/reference/sdlc/decide.playbook/decide.gears.md +28 -21
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -59
- package/reference/sdlc/decide.playbook/decide.playbook.js +155 -3696
- package/reference/sdlc/decide.playbook/decide.playbook.ts +186 -5244
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +8 -4
- package/reference/sdlc/decide.playbook/decide.registry.js +2 -2
- package/reference/sdlc/decide.playbook/decide.registry.ts +13 -6
- package/reference/sdlc/dev.playbook/dev.fsm.d.ts +116 -134
- package/reference/sdlc/dev.playbook/dev.fsm.js +517 -575
- package/reference/sdlc/dev.playbook/dev.fsm.ts +667 -787
- package/reference/sdlc/dev.playbook/dev.gears.md +52 -54
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +13 -10
- package/reference/sdlc/dev.playbook/dev.playbook.js +128 -120
- package/reference/sdlc/dev.playbook/dev.playbook.ts +143 -193
- package/reference/sdlc/dev.playbook/dev.registry.d.ts +3 -2
- package/reference/sdlc/dev.playbook/dev.registry.ts +6 -2
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +95 -88
- package/reference/sdlc/pr.playbook/pr.fsm.js +461 -518
- package/reference/sdlc/pr.playbook/pr.fsm.ts +533 -663
- package/reference/sdlc/pr.playbook/pr.gears.md +47 -63
- package/reference/sdlc/pr.playbook/pr.playbook.d.ts +13 -23
- package/reference/sdlc/pr.playbook/pr.playbook.js +118 -112
- package/reference/sdlc/pr.playbook/pr.playbook.ts +136 -190
- package/reference/sdlc/pr.playbook/pr.registry.d.ts +3 -2
- package/reference/sdlc/pr.playbook/pr.registry.ts +4 -2
- package/reference/sdlc/review.md +3 -3
- package/reference/sdlc/review.playbook/review.fsm.d.ts +150 -116
- package/reference/sdlc/review.playbook/review.fsm.js +479 -550
- package/reference/sdlc/review.playbook/review.fsm.ts +717 -662
- package/reference/sdlc/review.playbook/review.gears.md +32 -29
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -12
- package/reference/sdlc/review.playbook/review.playbook.js +180 -155
- package/reference/sdlc/review.playbook/review.playbook.ts +190 -230
- package/reference/sdlc/review.playbook/review.registry.d.ts +6 -5
- package/reference/sdlc/review.playbook/review.registry.js +3 -3
- package/reference/sdlc/review.playbook/review.registry.ts +9 -5
- package/slc/gears2fsm.md +77 -8
- package/slc/link.md +127 -60
- package/slc/materialize-link.mjs +51 -13
- package/slc/optimize.md +4 -1
- package/slc/prefix-prompts.mjs +29 -28
- package/slc/prefix.md +27 -24
- package/slc/slc.pin-inputs.json +11 -2
- package/slc/text2gears.md +71 -11
- package/src/runtime.d.ts +46 -2
- package/src/runtime.ts +42 -2
- package/src/xstate-playbook-runtime.d.ts +63 -23
- package/src/xstate-playbook-runtime.js +1637 -475
- package/src/xstate-playbook-runtime.ts +2105 -532
- package/src/xstate-runtime.d.ts +3 -4
- package/src/xstate-runtime.js +52 -0
- package/src/xstate-runtime.ts +59 -4
|
@@ -16,10 +16,12 @@ import { createTmuxPlayRuntime } from "@sublang/cligent/tmux-play";
|
|
|
16
16
|
import {
|
|
17
17
|
assertPlaybookEffectLedger,
|
|
18
18
|
emptyPlaybookEffectLedger,
|
|
19
|
+
isPlaybookEffectLedgerMonotonicExtension,
|
|
19
20
|
} from "../../../../src/xstate-runtime.js";
|
|
20
21
|
import {
|
|
21
22
|
assertPlaybookCaptainShellSnapshot,
|
|
22
23
|
createPlaybookCaptainShell,
|
|
24
|
+
projectUnresolvedEffects,
|
|
23
25
|
} from "../playbook-captain.js";
|
|
24
26
|
import {
|
|
25
27
|
adapterSdkFailureLines,
|
|
@@ -53,6 +55,7 @@ import {
|
|
|
53
55
|
assertCaptainSessionExecutionCompatible,
|
|
54
56
|
assertCaptainSessionsDirectoryUsable,
|
|
55
57
|
createCaptainSessionStore,
|
|
58
|
+
isUncertainTurnDiscardable,
|
|
56
59
|
projectCaptainSessionStructure,
|
|
57
60
|
SESSION_ID_PATTERN,
|
|
58
61
|
validateCaptainSessionExecutionProjection,
|
|
@@ -306,7 +309,7 @@ export async function runPlaybookRun(options = {}) {
|
|
|
306
309
|
if (!args.retryUncertain) {
|
|
307
310
|
const releaseError = await releaseLease(lease);
|
|
308
311
|
lease = undefined;
|
|
309
|
-
await reportUncertainSession(stderr, sessionId);
|
|
312
|
+
await reportUncertainSession(stderr, sessionId, !isUncertainTurnDiscardable(priorRecord));
|
|
310
313
|
if (releaseError !== undefined) {
|
|
311
314
|
await writeStream(
|
|
312
315
|
stderr,
|
|
@@ -791,11 +794,12 @@ export async function driveHeadlessCaptainTurn({
|
|
|
791
794
|
const replies = [];
|
|
792
795
|
let shell;
|
|
793
796
|
let host;
|
|
797
|
+
let createdHost;
|
|
794
798
|
let baselineSnapshot;
|
|
795
799
|
let uncertainRecord;
|
|
796
800
|
try {
|
|
797
801
|
try {
|
|
798
|
-
const created = await createCaptainSessionHost({
|
|
802
|
+
const created = createdHost = await createCaptainSessionHost({
|
|
799
803
|
config,
|
|
800
804
|
sessionId,
|
|
801
805
|
cwd,
|
|
@@ -895,6 +899,11 @@ export async function driveHeadlessCaptainTurn({
|
|
|
895
899
|
: cleanupFailure;
|
|
896
900
|
}
|
|
897
901
|
}
|
|
902
|
+
try {
|
|
903
|
+
if (createdHost?.getInterruptedSettlement(uncertainRecord?.uncertain?.attemptId)) {
|
|
904
|
+
await writeStream(stderr, 'The latest work is saved. Continue this session with a new instruction.\n');
|
|
905
|
+
}
|
|
906
|
+
} catch { /* Preserve the original turn or cleanup error. */ }
|
|
898
907
|
throw error;
|
|
899
908
|
}
|
|
900
909
|
}
|
|
@@ -961,54 +970,82 @@ function isCaptainSessionHostCleanupIncomplete(error) {
|
|
|
961
970
|
);
|
|
962
971
|
}
|
|
963
972
|
|
|
964
|
-
function
|
|
965
|
-
const
|
|
973
|
+
export function restoreInterruptedProgress(record, ledger) {
|
|
974
|
+
const base = record.snapshot;
|
|
975
|
+
const progress = record.uncertain.progress;
|
|
966
976
|
const current = assertPlaybookEffectLedger(ledger);
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
if (!isDeepStrictEqual(currentPrefix, checkpoint.boundaries)) {
|
|
970
|
-
throw new Error(
|
|
971
|
-
"Captain uncertain turn repository-effect reconciliation cannot restore a changed pre-turn boundary",
|
|
972
|
-
);
|
|
973
|
-
}
|
|
974
|
-
if (
|
|
975
|
-
!isDeepStrictEqual(current.logicalOperations, checkpoint.logicalOperations)
|
|
976
|
-
) {
|
|
977
|
-
throw new Error(
|
|
978
|
-
"Captain uncertain turn repository-effect reconciliation found deferred logical-operation progress and cannot replay the Boss turn",
|
|
979
|
-
);
|
|
977
|
+
if (!isPlaybookEffectLedgerMonotonicExtension(base.effectLedger, current)) {
|
|
978
|
+
throw new Error('Captain recovery evidence changed incompatibly');
|
|
980
979
|
}
|
|
981
|
-
const
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
);
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
);
|
|
980
|
+
const steps = progress?.steps ?? [];
|
|
981
|
+
const saved = progress?.snapshot;
|
|
982
|
+
const changedBoundaries = current.boundaries.filter((boundary, index) => !isDeepStrictEqual(boundary, base.effectLedger.boundaries[index]));
|
|
983
|
+
const changedOperations = current.logicalOperations.filter((operation, index) => !isDeepStrictEqual(operation, base.effectLedger.logicalOperations[index]));
|
|
984
|
+
const noWork = isUncertainTurnDiscardable(record);
|
|
985
|
+
const completion = steps.findLast((step) => step.kind === 'completion');
|
|
986
|
+
const facts = steps.map((step) => {
|
|
987
|
+
const name = `/${record.structuralProjection.catalog[step.playbookId].command}`;
|
|
988
|
+
if (step.kind === 'completion') return `${name} ${step.result.terminalOutcome?.kind === 'failure' ? 'stopped with a failure' : 'finished'}. ${step.result.description ?? 'No result description was supplied.'}`;
|
|
989
|
+
if (step.kind === 'answer') return `Captain selected the original task as the answer for ${name}. Questions: ${step.result.questions.map(({ question }) => JSON.stringify(question)).join('; ')}. Answer: ${JSON.stringify(step.result.instruction)}.`;
|
|
990
|
+
if (step.kind === 'preparation') return step.result === undefined
|
|
991
|
+
? `Captain started preparation for ${name}; it may have used tools. No result was saved.`
|
|
992
|
+
: `Captain preparation for ${name}: ${step.result.summary}`;
|
|
993
|
+
return `${name} ${step.kind} call: ${step.result === undefined ? 'started; no result was saved' : step.result?.status === undefined ? 'result saved' : `returned ${step.result.status}`}.`;
|
|
994
|
+
});
|
|
995
|
+
const completedRoots = steps.filter((step) => step.kind === 'completion' && step.result.retention !== 'keep').map((step) => ({ kind: 'clear', rootPlaybookId: step.playbookId }));
|
|
996
|
+
const report = {
|
|
997
|
+
retentionUpdates: completedRoots,
|
|
998
|
+
effects: projectUnresolvedEffects(current, [
|
|
999
|
+
...changedBoundaries.map(({ boundaryId }) => ({ kind: 'boundary', boundaryId })),
|
|
1000
|
+
...changedOperations.map(({ operationId }) => ({ kind: 'logical-operation', operationId })),
|
|
1001
|
+
]),
|
|
1002
|
+
text: [noWork
|
|
1003
|
+
? 'The last message was not processed. Your previous position and pending questions are restored; no work was repeated.'
|
|
1004
|
+
: saved
|
|
1005
|
+
? completion && !saved.frames?.length
|
|
1006
|
+
? 'The completed run is restored; no work was repeated.'
|
|
1007
|
+
: 'The run stopped. Its saved position is restored; no work was repeated. Choose how to continue. Before repeating unfinished work, confirm that the earlier worker has stopped and check any outside actions it may have taken.'
|
|
1008
|
+
: 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts].join('\n\n'),
|
|
1009
|
+
};
|
|
1010
|
+
if (noWork) return { snapshot: base, report };
|
|
1011
|
+
if (saved) {
|
|
1012
|
+
if (!isPlaybookEffectLedgerMonotonicExtension(saved.effectLedger, current)) throw new Error('Saved step evidence changed incompatibly');
|
|
1013
|
+
const frames = saved.frames?.map((frame) => {
|
|
1014
|
+
const checkpoint = frame.runtime.recoveryCheckpoint;
|
|
1015
|
+
const step = steps.find((entry) => entry.id === checkpoint?.id && entry.runtimeSessionId === frame.sessionId && entry.playbookId === frame.playbookId && entry.stateId === checkpoint?.stateId);
|
|
1016
|
+
const { failedEffectAttempt: priorAttempt, ...runtime } = frame.runtime;
|
|
1017
|
+
const interrupted = step !== undefined && checkpoint?.id === progress.positionStepId;
|
|
1018
|
+
const attempts = interrupted && priorAttempt
|
|
1019
|
+
? new Set(current.boundaries.filter(({ sequence }) => sequence > priorAttempt.boundaryPrefix).map(({ attemptId }) => attemptId)) : undefined;
|
|
1020
|
+
const failedEffectAttempt = attempts === undefined ? priorAttempt : attempts.size > 1 ? undefined
|
|
1021
|
+
: { boundaryPrefix: priorAttempt.boundaryPrefix, attemptId: attempts.values().next().value ?? null };
|
|
1022
|
+
return { ...frame, runtime: { ...runtime, effectLedger: current,
|
|
1023
|
+
...(failedEffectAttempt === undefined ? {} : { failedEffectAttempt }),
|
|
1024
|
+
...(step?.result === undefined || !interrupted ? {} : { recoveryCheckpoint: { ...checkpoint, result: step.result } }),
|
|
1025
|
+
} };
|
|
1026
|
+
});
|
|
1027
|
+
return { snapshot: assertPlaybookCaptainShellSnapshot({ ...saved, effectLedger: current, ...(frames ? { frames } : {}) }), report };
|
|
991
1028
|
}
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
...frame,
|
|
1003
|
-
runtime: { ...frame.runtime, effectLedger: current },
|
|
1004
|
-
};
|
|
1005
|
-
});
|
|
1029
|
+
const owners = new Set([...changedBoundaries, ...changedOperations, ...(progress?.steps ?? [])].map((entry) => entry.runtimeSessionId));
|
|
1030
|
+
const clears = Object.entries(record.retainedGenerations ?? {})
|
|
1031
|
+
.filter(([, generation]) => generation.frames.some((frame) => owners.has(frame.sessionId) || owners.has(frame.runtime.retainedEffectSourceSessionId)))
|
|
1032
|
+
.map(([rootPlaybookId]) => ({ kind: 'clear', rootPlaybookId }));
|
|
1033
|
+
const { frames, pendingBossQuestions, lastError, retainedEffectReconciliation, ...chat } = base;
|
|
1034
|
+
return {
|
|
1035
|
+
snapshot: assertPlaybookCaptainShellSnapshot({ ...chat, mode: 'chat', effectLedger: current }),
|
|
1036
|
+
report: { ...report, retentionUpdates: [...completedRoots, ...clears], unresolvedEffects: report.effects },
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1006
1039
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1040
|
+
// A drained turn can retain its actual stopped stack without replaying work.
|
|
1041
|
+
// This runs inside the shared Captain boundary, before any host can dispose it.
|
|
1042
|
+
export async function settleInterruptedRecovery(shell, lease) {
|
|
1043
|
+
if (typeof lease.read !== 'function') return;
|
|
1044
|
+
const record = await lease.read();
|
|
1045
|
+
if (!record?.uncertain?.progress) return;
|
|
1046
|
+
const settlement = shell.exportSettlement();
|
|
1047
|
+
if (!settlement) return;
|
|
1048
|
+
return { attemptId: record.uncertain.attemptId, record: await lease.settle({ attemptId: record.uncertain.attemptId, ...settlement }) };
|
|
1012
1049
|
}
|
|
1013
1050
|
|
|
1014
1051
|
export async function createCaptainSessionHost({
|
|
@@ -1057,37 +1094,22 @@ export async function createCaptainSessionHost({
|
|
|
1057
1094
|
}
|
|
1058
1095
|
const currentEffectLedger = () =>
|
|
1059
1096
|
effectLedgerSnapshotFromCapabilities(hostCapabilities);
|
|
1097
|
+
const interrupted = reconcileUncertainTurnReplay && typeof sessionLease.read === 'function'
|
|
1098
|
+
? await sessionLease.read() : undefined;
|
|
1060
1099
|
let sourceSnapshot = restoreSnapshot;
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
recovered?.state === "settled" &&
|
|
1071
|
-
isDeepStrictEqual(recovered.snapshot.effectLedger, currentEffectLedger())
|
|
1072
|
-
) {
|
|
1073
|
-
sourceSnapshot = recovered.snapshot;
|
|
1100
|
+
let interruptedReport;
|
|
1101
|
+
if (interrupted) {
|
|
1102
|
+
const restored = restoreInterruptedProgress(interrupted, currentEffectLedger());
|
|
1103
|
+
sourceSnapshot = restored.snapshot;
|
|
1104
|
+
interruptedReport = restored.report;
|
|
1105
|
+
} else if (sourceSnapshot && !isDeepStrictEqual(sourceSnapshot.effectLedger, currentEffectLedger())) {
|
|
1106
|
+
const recovered = await sessionLease.read?.();
|
|
1107
|
+
if (recovered?.state !== 'settled' || !isDeepStrictEqual(recovered.snapshot.effectLedger, currentEffectLedger())) {
|
|
1108
|
+
throw new Error('Captain session effect ledger requires reconciliation before source-state restoration');
|
|
1074
1109
|
}
|
|
1110
|
+
sourceSnapshot = recovered.snapshot;
|
|
1075
1111
|
}
|
|
1076
|
-
if (sourceSnapshot !== undefined &&
|
|
1077
|
-
sourceSnapshot = reconcileWholeTurnReplaySnapshot(
|
|
1078
|
-
sourceSnapshot,
|
|
1079
|
-
currentEffectLedger(),
|
|
1080
|
-
config.catalog,
|
|
1081
|
-
);
|
|
1082
|
-
} else if (
|
|
1083
|
-
sourceSnapshot !== undefined &&
|
|
1084
|
-
!isDeepStrictEqual(sourceSnapshot.effectLedger, currentEffectLedger())
|
|
1085
|
-
) {
|
|
1086
|
-
throw new Error(
|
|
1087
|
-
"Captain session effect ledger requires reconciliation before source-state restoration",
|
|
1088
|
-
);
|
|
1089
|
-
}
|
|
1090
|
-
if (sourceSnapshot !== undefined && typeof sessionLease.consumeHints === "function") {
|
|
1112
|
+
if (sourceSnapshot !== undefined && !interrupted && typeof sessionLease.consumeHints === "function") {
|
|
1091
1113
|
sourceSnapshot = attachSessionHints(sourceSnapshot, await sessionLease.consumeHints());
|
|
1092
1114
|
}
|
|
1093
1115
|
const bufferedRecords = [];
|
|
@@ -1104,8 +1126,12 @@ export async function createCaptainSessionHost({
|
|
|
1104
1126
|
else await forwardRecord(record);
|
|
1105
1127
|
} }];
|
|
1106
1128
|
const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
|
|
1129
|
+
abortPreparation: (reason) => host?.abortActiveTurn(typeof reason === 'string' ? reason : 'Captain preparation exceeded its 150-second limit'),
|
|
1107
1130
|
loadModule,
|
|
1108
1131
|
hostCapabilities,
|
|
1132
|
+
...(typeof sessionLease.recordProgress === 'function' ? {
|
|
1133
|
+
recordProgress: (change) => sessionLease.recordProgress(change),
|
|
1134
|
+
} : {}),
|
|
1109
1135
|
continuity: {
|
|
1110
1136
|
async beforeCall(participantId) { sessionLease.clearHint?.(participantId); await sessionLease.assertOwner(); },
|
|
1111
1137
|
acknowledged(participantId, token) { sessionLease.acknowledgeHint?.(participantId, token); },
|
|
@@ -1122,8 +1148,9 @@ export async function createCaptainSessionHost({
|
|
|
1122
1148
|
: {}),
|
|
1123
1149
|
});
|
|
1124
1150
|
let host;
|
|
1151
|
+
let interruptedSettlement;
|
|
1125
1152
|
try {
|
|
1126
|
-
const captain = captainHostBoundary(shell, sourceSnapshot);
|
|
1153
|
+
const captain = captainHostBoundary(shell, sourceSnapshot, sessionLease, (record) => { interruptedSettlement = record; });
|
|
1127
1154
|
host = await createHostRuntime({
|
|
1128
1155
|
captain,
|
|
1129
1156
|
captainConfig: projectHostAgent(
|
|
@@ -1147,7 +1174,7 @@ export async function createCaptainSessionHost({
|
|
|
1147
1174
|
}
|
|
1148
1175
|
if (
|
|
1149
1176
|
sourceSnapshot !== undefined &&
|
|
1150
|
-
!isDeepStrictEqual(snapshot, sourceSnapshot)
|
|
1177
|
+
!isDeepStrictEqual(snapshot, assertPlaybookCaptainShellSnapshot(sourceSnapshot))
|
|
1151
1178
|
) {
|
|
1152
1179
|
throw new Error("restored Captain snapshot changed before the Boss turn");
|
|
1153
1180
|
}
|
|
@@ -1163,7 +1190,8 @@ export async function createCaptainSessionHost({
|
|
|
1163
1190
|
}
|
|
1164
1191
|
presentationReady = true;
|
|
1165
1192
|
for (const record of bufferedRecords) await forwardRecord(record);
|
|
1166
|
-
|
|
1193
|
+
if (interruptedReport) shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
|
|
1194
|
+
return { shell, host, snapshot, reconcileRepositoryEffects, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
|
|
1167
1195
|
} catch (error) {
|
|
1168
1196
|
let cleanupError;
|
|
1169
1197
|
try {
|
|
@@ -1215,13 +1243,26 @@ export async function installRetainedGenerationsForLaunch({
|
|
|
1215
1243
|
|
|
1216
1244
|
// PBCLI-20: restoration enters through the host's one init boundary. The
|
|
1217
1245
|
// restored shell is fresh and receives restore instead of init, never both.
|
|
1218
|
-
function captainHostBoundary(shell, restoreSnapshot) {
|
|
1246
|
+
function captainHostBoundary(shell, restoreSnapshot, sessionLease, onInterrupted) {
|
|
1219
1247
|
return {
|
|
1220
1248
|
init: (session) =>
|
|
1221
1249
|
restoreSnapshot === undefined
|
|
1222
1250
|
? shell.init(session)
|
|
1223
1251
|
: shell.restore(session, restoreSnapshot),
|
|
1224
|
-
handleBossTurn
|
|
1252
|
+
async handleBossTurn(turn, context) {
|
|
1253
|
+
onInterrupted(undefined);
|
|
1254
|
+
try {
|
|
1255
|
+
const result = await shell.handleBossTurn(turn, context);
|
|
1256
|
+
context.signal.throwIfAborted();
|
|
1257
|
+
return result;
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
try { onInterrupted(await settleInterruptedRecovery(shell, sessionLease)); }
|
|
1260
|
+
catch (settlementError) {
|
|
1261
|
+
throw new AggregateError([error, settlementError], `Captain turn stopped (${message(error)}) and its state could not be saved (${message(settlementError)})`);
|
|
1262
|
+
}
|
|
1263
|
+
throw error;
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
1225
1266
|
prepareDispose: () => shell.prepareDispose?.(),
|
|
1226
1267
|
dispose: () => shell.dispose?.(),
|
|
1227
1268
|
};
|
|
@@ -1718,14 +1759,16 @@ async function reportHeadlessReplay(channel, status) {
|
|
|
1718
1759
|
await channel.flushWarning();
|
|
1719
1760
|
}
|
|
1720
1761
|
|
|
1721
|
-
async function reportUncertainSession(stderr, sessionId) {
|
|
1762
|
+
async function reportUncertainSession(stderr, sessionId, hasRecordedWork = false) {
|
|
1722
1763
|
await writeStream(
|
|
1723
1764
|
stderr,
|
|
1724
1765
|
[
|
|
1725
1766
|
`playbook run: Captain session ${JSON.stringify(sessionId)} has an uncertain turn and will not be replayed automatically`,
|
|
1726
|
-
|
|
1767
|
+
hasRecordedWork
|
|
1768
|
+
? "Retry restores and reports the saved work without running it. Discard is unavailable because work or repository evidence was recorded."
|
|
1769
|
+
: "Retry restores and reports without running work. Discard abandons the attempted turn only when no work was recorded.",
|
|
1727
1770
|
`playbook run --session ${sessionId} --retry-uncertain`,
|
|
1728
|
-
`playbook run --session ${sessionId} --discard-uncertain
|
|
1771
|
+
...(hasRecordedWork ? [] : [`playbook run --session ${sessionId} --discard-uncertain`]),
|
|
1729
1772
|
"",
|
|
1730
1773
|
].join("\n"),
|
|
1731
1774
|
);
|
|
@@ -1827,15 +1870,15 @@ function runHelpText(userConfigPath) {
|
|
|
1827
1870
|
"An ordinary continued run restores that stored structure and working",
|
|
1828
1871
|
"directory, then reads current config and overlays for model, effort,",
|
|
1829
1872
|
"and fast mode.",
|
|
1830
|
-
"Uncertain retry
|
|
1873
|
+
"Uncertain retry only restores and reports; later work uses current settings.",
|
|
1831
1874
|
"",
|
|
1832
1875
|
"Options:",
|
|
1833
1876
|
" --with <path> overlay a generic config fragment (repeatable)",
|
|
1834
1877
|
" --no-provision do not provision thin filesystem registry engines",
|
|
1835
1878
|
" --continue prefer this working directory, else global newest",
|
|
1836
1879
|
" --session <id> reply to one durable Captain session UUID",
|
|
1837
|
-
" --retry-uncertain
|
|
1838
|
-
" --discard-uncertain discard
|
|
1880
|
+
" --retry-uncertain restore and report interrupted work without running it",
|
|
1881
|
+
" --discard-uncertain discard only an attempt with no recorded work",
|
|
1839
1882
|
' --json print exactly {"sessionId", "reply"}',
|
|
1840
1883
|
" --verbose print Captain telemetry topics to stderr",
|
|
1841
1884
|
" -h, --help print this help without reading input or config",
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { createCaptainSessionStore, projectCaptainSessionStructure } from './session-store.js';
|
|
5
|
+
import { createCaptainSessionStore, isUncertainTurnDiscardable, projectCaptainSessionStructure } from './session-store.js';
|
|
6
6
|
import { createCaptainSessionHost, executionConfigFromPlan, installRetainedGenerationsForLaunch, validateFrozenExecutionConfig } from './run.js';
|
|
7
7
|
import { createReplayRecordObserver } from './replay-observer.js';
|
|
8
8
|
|
|
9
|
+
const RECOVERY_REOPEN = "This controller stopped with an uncertain turn. Dispose it, reopen with mode: 'recover', then call recover() without new input.";
|
|
10
|
+
|
|
9
11
|
/** Own one session lease and the same durable turn transaction as the CLIs. */
|
|
10
12
|
export async function openSessionHost(options) {
|
|
11
13
|
const store = options.store ?? createCaptainSessionStore({ sessionsDir: options.sessionsDir });
|
|
@@ -19,9 +21,10 @@ export async function openSessionHost(options) {
|
|
|
19
21
|
let record = await lease.read();
|
|
20
22
|
if (record !== undefined) await lease.assertContinuable({ cwd: options.cwd });
|
|
21
23
|
record = await lease.recoverUnresolvedEffectAbandonment();
|
|
24
|
+
if (options.mode === 'recover') retryPending = record?.state === 'uncertain';
|
|
22
25
|
if (options.mode === 'new' && record !== undefined) throw new Error('session already exists');
|
|
23
26
|
if (options.mode !== 'new' && options.sessionId && record === undefined) throw new Error('session does not exist');
|
|
24
|
-
if (record?.state === 'uncertain' && !retryPending) throw new Error('session has an uncertain turn; select Retry or Discard');
|
|
27
|
+
if (record?.state === 'uncertain' && !retryPending) throw new Error(isUncertainTurnDiscardable(record) ? 'session has an uncertain turn; select Retry or Discard' : 'session has recorded work or repository evidence; select Retry to restore and report it');
|
|
25
28
|
if (retryPending && record?.state !== 'uncertain') throw new Error('session has no uncertain turn to retry');
|
|
26
29
|
const cwd = options.cwd ?? record?.cwd ?? process.cwd();
|
|
27
30
|
const selected = retryPending ? record.uncertain.attemptedExecutionProjection : options.config ?? (options.plan ? executionConfigFromPlan(options.plan) : record?.lastAppliedExecutionProjection);
|
|
@@ -43,16 +46,20 @@ export async function openSessionHost(options) {
|
|
|
43
46
|
await installRetainedGenerationsForLaunch({ lease, shell: created.shell, ...(record === undefined ? { freshBoundary: { cwd, structuralProjection: structure, executionProjection: config, snapshot: created.snapshot } } : {}), retainedGenerations: record?.retainedGenerations ?? {}, reconcileRepositoryEffects: created.reconcileRepositoryEffects });
|
|
44
47
|
record = await lease.read();
|
|
45
48
|
await replay.flushStoredRecords();
|
|
46
|
-
const
|
|
49
|
+
const assertIdle = () => {
|
|
47
50
|
if (closed || closing) throw new Error('session host is closing');
|
|
48
51
|
if (active) throw new Error('session turn is already active');
|
|
52
|
+
};
|
|
53
|
+
const execute = async (input, retry, actionId, shellActionId) => {
|
|
54
|
+
assertIdle();
|
|
55
|
+
let attemptId;
|
|
49
56
|
const operation = (async () => {
|
|
50
57
|
let prior = await lease.read();
|
|
51
58
|
if (retry) {
|
|
52
59
|
if (prior?.state !== 'uncertain') throw new Error('session has no uncertain turn to retry');
|
|
53
60
|
input = prior.uncertain.input;
|
|
54
|
-
if (!retryPending) throw new Error(
|
|
55
|
-
} else if (prior?.state !== 'settled') throw new Error(
|
|
61
|
+
if (!retryPending) throw new Error(RECOVERY_REOPEN);
|
|
62
|
+
} else if (prior?.state !== 'settled') throw new Error(RECOVERY_REOPEN);
|
|
56
63
|
if (actionId === undefined && shellActionId === undefined && (typeof input !== 'string' || input.trim().length === 0)) throw new Error('session input must be nonempty');
|
|
57
64
|
await created.reconcileRepositoryEffects();
|
|
58
65
|
// The selection is made after reconciliation, so the advertised
|
|
@@ -66,7 +73,7 @@ export async function openSessionHost(options) {
|
|
|
66
73
|
input = created.shell.submitShellAction(shellActionId);
|
|
67
74
|
}
|
|
68
75
|
terminal = undefined; replies = [];
|
|
69
|
-
|
|
76
|
+
attemptId = options.createAttemptId?.() ?? randomUUID();
|
|
70
77
|
const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attemptId, attemptedExecutionProjection: config });
|
|
71
78
|
retryPending = false;
|
|
72
79
|
await replay.flushStoredRecords();
|
|
@@ -80,10 +87,23 @@ export async function openSessionHost(options) {
|
|
|
80
87
|
await replay.flushStoredRecords();
|
|
81
88
|
await options.onCheckpoint?.(record);
|
|
82
89
|
return record;
|
|
83
|
-
})()
|
|
90
|
+
})().catch(async (error) => {
|
|
91
|
+
try {
|
|
92
|
+
const parked = created.getInterruptedSettlement(attemptId);
|
|
93
|
+
if (parked?.state === 'settled') {
|
|
94
|
+
record = parked;
|
|
95
|
+
await replay.flushStoredRecords();
|
|
96
|
+
await options.onCheckpoint?.(record);
|
|
97
|
+
}
|
|
98
|
+
} catch (reportError) {
|
|
99
|
+
throw new AggregateError([error, reportError], 'Captain turn stopped and its saved state could not be reported');
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
});
|
|
84
103
|
active = operation;
|
|
85
104
|
try { return await operation; } finally { active = undefined; }
|
|
86
105
|
};
|
|
106
|
+
|
|
87
107
|
const dispose = () => {
|
|
88
108
|
if (closed) return Promise.resolve();
|
|
89
109
|
if (closing) return closing;
|
|
@@ -94,7 +114,19 @@ export async function openSessionHost(options) {
|
|
|
94
114
|
})();
|
|
95
115
|
return closing;
|
|
96
116
|
};
|
|
97
|
-
|
|
117
|
+
const recover = async (input) => {
|
|
118
|
+
assertIdle();
|
|
119
|
+
const prior = await lease.read();
|
|
120
|
+
assertIdle();
|
|
121
|
+
if (prior?.state === 'uncertain' && !retryPending) throw new Error(RECOVERY_REOPEN);
|
|
122
|
+
if (retryPending) {
|
|
123
|
+
if (input !== undefined) throw new Error('Restore and report the interrupted run before supplying new input');
|
|
124
|
+
return execute(undefined, true);
|
|
125
|
+
}
|
|
126
|
+
if (typeof input !== 'string' || !input.trim()) throw new Error('Recovery of a paused task requires a Boss instruction');
|
|
127
|
+
return execute(input, false);
|
|
128
|
+
};
|
|
129
|
+
return Object.freeze({ sessionId, host: created.host, shell: created.shell, lease, read: () => lease.read(), handleBossTurn: (input) => execute(input, false), recover, listRuntimeActions: () => created.shell.describeRuntimeActions?.() ?? Object.freeze([]), submitRuntimeAction: (actionId) => execute(undefined, false, actionId), listShellActions: () => created.shell.describeShellActions?.() ?? Object.freeze([]), submitShellAction: (actionId) => execute(undefined, false, undefined, actionId), retry: () => execute(undefined, true), dispose });
|
|
98
130
|
} catch (cause) {
|
|
99
131
|
const failures = [cause];
|
|
100
132
|
let disposed = true;
|