instar 1.3.343 → 1.3.345
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/init.d.ts +4 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/reflect.d.ts.map +1 -1
- package/dist/commands/reflect.js +7 -0
- package/dist/commands/reflect.js.map +1 -1
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +126 -8
- package/dist/commands/server.js.map +1 -1
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js.map +1 -1
- package/dist/core/Config.d.ts +12 -3
- package/dist/core/Config.d.ts.map +1 -1
- package/dist/core/Config.js +40 -6
- package/dist/core/Config.js.map +1 -1
- package/dist/core/FrameworkSessionStore.d.ts +1 -1
- package/dist/core/FrameworkSessionStore.d.ts.map +1 -1
- package/dist/core/PiCliIntelligenceProvider.d.ts +52 -0
- package/dist/core/PiCliIntelligenceProvider.d.ts.map +1 -0
- package/dist/core/PiCliIntelligenceProvider.js +71 -0
- package/dist/core/PiCliIntelligenceProvider.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +57 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/PreCompactionFlush.d.ts +1 -1
- package/dist/core/PreCompactionFlush.d.ts.map +1 -1
- package/dist/core/ResumeValidator.d.ts +1 -1
- package/dist/core/ResumeValidator.d.ts.map +1 -1
- package/dist/core/SessionManager.d.ts +130 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +545 -10
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/TopicFrameworksStore.d.ts.map +1 -1
- package/dist/core/TopicFrameworksStore.js +3 -0
- package/dist/core/TopicFrameworksStore.js.map +1 -1
- package/dist/core/frameworkInjectionProcesses.d.ts.map +1 -1
- package/dist/core/frameworkInjectionProcesses.js +4 -0
- package/dist/core/frameworkInjectionProcesses.js.map +1 -1
- package/dist/core/frameworkSessionLaunch.d.ts +8 -0
- package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
- package/dist/core/frameworkSessionLaunch.js +73 -0
- package/dist/core/frameworkSessionLaunch.js.map +1 -1
- package/dist/core/intelligenceProviderFactory.d.ts +15 -1
- package/dist/core/intelligenceProviderFactory.d.ts.map +1 -1
- package/dist/core/intelligenceProviderFactory.js +39 -1
- package/dist/core/intelligenceProviderFactory.js.map +1 -1
- package/dist/core/types.d.ts +88 -8
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/messaging/SpawnRequestManager.d.ts +14 -0
- package/dist/messaging/SpawnRequestManager.d.ts.map +1 -1
- package/dist/messaging/SpawnRequestManager.js +17 -0
- package/dist/messaging/SpawnRequestManager.js.map +1 -1
- package/dist/messaging/shared/telegramRelayPrompt.d.ts +1 -1
- package/dist/messaging/shared/telegramRelayPrompt.d.ts.map +1 -1
- package/dist/monitoring/ApprenticeshipCycleStore.d.ts +59 -1
- package/dist/monitoring/ApprenticeshipCycleStore.d.ts.map +1 -1
- package/dist/monitoring/ApprenticeshipCycleStore.js +77 -2
- package/dist/monitoring/ApprenticeshipCycleStore.js.map +1 -1
- package/dist/monitoring/ReapLog.d.ts +6 -0
- package/dist/monitoring/ReapLog.d.ts.map +1 -1
- package/dist/monitoring/ReapLog.js +1 -0
- package/dist/monitoring/ReapLog.js.map +1 -1
- package/dist/monitoring/SessionReaper.d.ts +1 -1
- package/dist/monitoring/SessionReaper.d.ts.map +1 -1
- package/dist/monitoring/SessionReaper.js.map +1 -1
- package/dist/monitoring/frameworkActivitySignals.d.ts.map +1 -1
- package/dist/monitoring/frameworkActivitySignals.js +21 -0
- package/dist/monitoring/frameworkActivitySignals.js.map +1 -1
- package/dist/monitoring/frameworkProcessSignals.d.ts.map +1 -1
- package/dist/monitoring/frameworkProcessSignals.js +22 -0
- package/dist/monitoring/frameworkProcessSignals.js.map +1 -1
- package/dist/providers/adapters/pi-cli/capabilities.d.ts +31 -0
- package/dist/providers/adapters/pi-cli/capabilities.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/capabilities.js +40 -0
- package/dist/providers/adapters/pi-cli/capabilities.js.map +1 -0
- package/dist/providers/adapters/pi-cli/config.d.ts +65 -0
- package/dist/providers/adapters/pi-cli/config.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/config.js +43 -0
- package/dist/providers/adapters/pi-cli/config.js.map +1 -0
- package/dist/providers/adapters/pi-cli/control/piHardKill.d.ts +19 -0
- package/dist/providers/adapters/pi-cli/control/piHardKill.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/control/piHardKill.js +67 -0
- package/dist/providers/adapters/pi-cli/control/piHardKill.js.map +1 -0
- package/dist/providers/adapters/pi-cli/errors.d.ts +43 -0
- package/dist/providers/adapters/pi-cli/errors.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/errors.js +71 -0
- package/dist/providers/adapters/pi-cli/errors.js.map +1 -0
- package/dist/providers/adapters/pi-cli/index.d.ts +39 -0
- package/dist/providers/adapters/pi-cli/index.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/index.js +69 -0
- package/dist/providers/adapters/pi-cli/index.js.map +1 -0
- package/dist/providers/adapters/pi-cli/observability/sessionId.d.ts +27 -0
- package/dist/providers/adapters/pi-cli/observability/sessionId.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/observability/sessionId.js +53 -0
- package/dist/providers/adapters/pi-cli/observability/sessionId.js.map +1 -0
- package/dist/providers/adapters/pi-cli/policy.d.ts +44 -0
- package/dist/providers/adapters/pi-cli/policy.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/policy.js +74 -0
- package/dist/providers/adapters/pi-cli/policy.js.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.d.ts +34 -0
- package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.js +194 -0
- package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.js.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.d.ts +33 -0
- package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.js +166 -0
- package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.js.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/piSpawn.d.ts +87 -0
- package/dist/providers/adapters/pi-cli/transport/piSpawn.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/piSpawn.js +242 -0
- package/dist/providers/adapters/pi-cli/transport/piSpawn.js.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/rpcClient.d.ts +103 -0
- package/dist/providers/adapters/pi-cli/transport/rpcClient.d.ts.map +1 -0
- package/dist/providers/adapters/pi-cli/transport/rpcClient.js +238 -0
- package/dist/providers/adapters/pi-cli/transport/rpcClient.js.map +1 -0
- package/dist/providers/bootRegistration.d.ts +38 -0
- package/dist/providers/bootRegistration.d.ts.map +1 -1
- package/dist/providers/bootRegistration.js +52 -0
- package/dist/providers/bootRegistration.js.map +1 -1
- package/dist/providers/parity/rules/skillParityRule.d.ts.map +1 -1
- package/dist/providers/parity/rules/skillParityRule.js +6 -0
- package/dist/providers/parity/rules/skillParityRule.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +3 -2
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/scheduler/JobScheduler.d.ts.map +1 -1
- package/dist/scheduler/JobScheduler.js +22 -0
- package/dist/scheduler/JobScheduler.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +10 -3
- package/dist/server/routes.js.map +1 -1
- package/dist/threadline/PipeSessionSpawner.d.ts +23 -0
- package/dist/threadline/PipeSessionSpawner.d.ts.map +1 -1
- package/dist/threadline/PipeSessionSpawner.js +27 -0
- package/dist/threadline/PipeSessionSpawner.js.map +1 -1
- package/package.json +2 -2
- package/scripts/lint-no-direct-destructive.js +3 -0
- package/scripts/lint-no-unfunneled-headless-launch.js +128 -0
- package/src/data/builtin-manifest.json +65 -65
- package/src/scaffold/templates.ts +3 -2
- package/upgrades/1.3.344.md +46 -0
- package/upgrades/1.3.345.md +154 -0
- package/upgrades/side-effects/apprenticeship-layer-balance.md +50 -0
- package/upgrades/side-effects/june15-headless-spawn-reroute.md +105 -0
- package/upgrades/side-effects/pi-harness-integration.md +82 -0
|
@@ -22,6 +22,7 @@ import { detectRateLimited } from '../monitoring/rateLimitDetection.js';
|
|
|
22
22
|
const execFileAsync = promisify(execFile);
|
|
23
23
|
import { buildInteractiveLaunch, buildHeadlessLaunch, claudeHeadlessExtraFlags, resolveInteractiveFramework, resolveModelForFramework, } from './frameworkSessionLaunch.js';
|
|
24
24
|
import { frameworkFromEnv } from './intelligenceProviderFactory.js';
|
|
25
|
+
import { decideSdkVsSubscription, DEFAULT_SAFETY_MARGIN_FRACTION } from '../providers/costAwareRouting.js';
|
|
25
26
|
import { resolveCodexLaunchModelWithUsage, } from '../providers/adapters/openai-codex/observability/codexModelSwapPolicy.js';
|
|
26
27
|
import { buildInjectionTag } from '../types/pipeline.js';
|
|
27
28
|
import { sanitizeSenderName, sanitizeTopicName } from '../utils/sanitize.js';
|
|
@@ -240,6 +241,19 @@ export class SessionManager extends EventEmitter {
|
|
|
240
241
|
pendingInjects;
|
|
241
242
|
/** Per-session shim directory root (one subdir per session). Used for K9 mandatory shim. */
|
|
242
243
|
shimRoot = null;
|
|
244
|
+
/**
|
|
245
|
+
* SDK-credit reader for the june15-headless-spawn-reroute 'auto' decision
|
|
246
|
+
* (PR2). Plumbed from server boot's anthropicRegistration.readSdkCredit (the
|
|
247
|
+
* SAME TTL-cached reader PR1's routing policy uses, so the two routing layers
|
|
248
|
+
* cannot drift). Returns null when credit state is unknown → the shared
|
|
249
|
+
* decideSdkVsSubscription falls to the subscription floor. Unset (tests/
|
|
250
|
+
* standalone) ⇒ 'auto' is treated as always-reroute-on-unknown (== subscription
|
|
251
|
+
* floor), matching the pure function's null-snapshot contract. */
|
|
252
|
+
readSdkCredit;
|
|
253
|
+
/** Last reroute-decision per framework, so the transition-only log line fires
|
|
254
|
+
* on a CHANGE rather than every spawn (mirrors PR1's onRoute). Keyed by
|
|
255
|
+
* framework; value is the resolved path string. */
|
|
256
|
+
lastRerouteDecision = new Map();
|
|
243
257
|
constructor(config, state) {
|
|
244
258
|
super();
|
|
245
259
|
this.config = config;
|
|
@@ -401,6 +415,17 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
401
415
|
setReapGuard(guard) {
|
|
402
416
|
this.reapGuard = guard;
|
|
403
417
|
}
|
|
418
|
+
/**
|
|
419
|
+
* Wire the SDK-credit reader used by the headless-spawn-reroute 'auto'
|
|
420
|
+
* decision (june15-headless-spawn-reroute, PR2). Server boot passes
|
|
421
|
+
* anthropicRegistration.readSdkCredit — the same TTL-cached reader PR1's
|
|
422
|
+
* routing policy uses — so the reroute decision and the intelligence-funnel
|
|
423
|
+
* routing share ONE credit source and can't drift. Unset ⇒ 'auto' resolves to
|
|
424
|
+
* the subscription floor (null-snapshot contract of decideSdkVsSubscription).
|
|
425
|
+
*/
|
|
426
|
+
setSdkCreditReader(reader) {
|
|
427
|
+
this.readSdkCredit = reader;
|
|
428
|
+
}
|
|
404
429
|
/**
|
|
405
430
|
* Flag/unflag a session as long-`indeterminate` (UNIFIED-SESSION-LIFECYCLE §P5).
|
|
406
431
|
* Driven by the StaleSessionBackstop. A flagged session still counts toward the
|
|
@@ -743,6 +768,59 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
743
768
|
continue;
|
|
744
769
|
}
|
|
745
770
|
this.recordBuildContext(session.tmuxSession);
|
|
771
|
+
// ── Rerouted-interactive completion branch (june15-headless-spawn-
|
|
772
|
+
// reroute, PR2 O1) ──
|
|
773
|
+
// A rerouted session (completionMode==='pattern') never exits on task
|
|
774
|
+
// success, so completion is detected by (a) its OWN sentinel pattern and
|
|
775
|
+
// (b) a hard lifetime cap as belt-and-suspenders. This branch handles
|
|
776
|
+
// those sessions and `continue`s; the default lane (completionMode unset
|
|
777
|
+
// or 'exit') falls through to today's global detectCompletion below,
|
|
778
|
+
// byte-for-byte unchanged.
|
|
779
|
+
if (session.completionMode === 'pattern') {
|
|
780
|
+
// (a) Sentinel match → terminate via the SUCCESS path. We use
|
|
781
|
+
// finalStatus:'completed' so sessionComplete carries status
|
|
782
|
+
// 'completed' → JobScheduler records SUCCESS (JobScheduler.ts:1109
|
|
783
|
+
// maps only 'killed' → 'timeout'). A sentinel completion is a clean
|
|
784
|
+
// finish, NOT a timeout.
|
|
785
|
+
if (!this.config.protectedSessions.includes(session.tmuxSession) &&
|
|
786
|
+
this.detectSessionCompletion(session)) {
|
|
787
|
+
console.log(`[SessionManager] Rerouted session "${session.name}" completed (sentinel detected). Reaping as success.`);
|
|
788
|
+
await this.terminateSession(session.id, 'sentinel-complete', {
|
|
789
|
+
finalStatus: 'completed',
|
|
790
|
+
disposition: 'terminal',
|
|
791
|
+
});
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
// (b) Hard lifetime cap → kill + report. This IS a timeout (the model
|
|
795
|
+
// never printed the sentinel), so finalStatus:'killed' → JobScheduler
|
|
796
|
+
// records 'timeout'. The cap bounds the leak when the model phrases
|
|
797
|
+
// its finish differently than the sentinel.
|
|
798
|
+
if (session.startedAt && session.maxLifetimeMinutes) {
|
|
799
|
+
const lifeElapsedMin = (Date.now() - new Date(session.startedAt).getTime()) / 60000;
|
|
800
|
+
if (lifeElapsedMin > session.maxLifetimeMinutes &&
|
|
801
|
+
!this.config.protectedSessions.includes(session.tmuxSession)) {
|
|
802
|
+
console.warn(`[SessionManager] Rerouted session "${session.name}" exceeded its hard lifetime (${Math.round(lifeElapsedMin)}m > ${session.maxLifetimeMinutes}m) without a completion sentinel. Killing (timeout).`);
|
|
803
|
+
const killed = await this.terminateSession(session.id, 'rerouted-lifetime', {
|
|
804
|
+
finalStatus: 'killed',
|
|
805
|
+
disposition: 'terminal',
|
|
806
|
+
});
|
|
807
|
+
if (killed.terminated) {
|
|
808
|
+
DegradationReporter.getInstance().report({
|
|
809
|
+
feature: 'SessionManager.monitorTick.reroutedLifetime',
|
|
810
|
+
primary: 'rerouted-interactive session completes via its sentinel within the lifetime cap',
|
|
811
|
+
fallback: 'killed on the hard lifetime cap (recorded as timeout)',
|
|
812
|
+
reason: `Session "${session.name}" ran ${Math.round(lifeElapsedMin)}m without printing its completion sentinel`,
|
|
813
|
+
impact: 'Rerouted job recorded a timeout; it reruns cleanly on its next trigger',
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// Still running, no sentinel, under lifetime — leave it; re-check next
|
|
820
|
+
// tick. Skip the rest of the loop body (the default exit/idle paths
|
|
821
|
+
// below are tuned for the headless lane, not a held REPL).
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
746
824
|
// Gemini CLI can complete a Telegram turn by rendering the final
|
|
747
825
|
// assistant block in the TUI without executing the relay script. When a
|
|
748
826
|
// topic-bound injection is still pending, surface that completed block
|
|
@@ -1031,6 +1109,89 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1031
1109
|
this.monitorInterval = null;
|
|
1032
1110
|
}
|
|
1033
1111
|
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Current host memory-pressure tier (june15-headless-spawn-reroute PR2, O2).
|
|
1114
|
+
* Extracted from getSessionDiagnostics' inline computation so the reroute
|
|
1115
|
+
* pre-spawn gate and the diagnostics surface read ONE definition of pressure.
|
|
1116
|
+
* The reroute gate refuses to hold a fresh ~200-500MB REPL when the host is
|
|
1117
|
+
* already 'high'/'critical' — the 2026-06-05 laptop-meltdown class.
|
|
1118
|
+
*/
|
|
1119
|
+
currentMemoryPressure() {
|
|
1120
|
+
const totalMem = os.totalmem();
|
|
1121
|
+
const freeMem = os.freemem();
|
|
1122
|
+
const usedPercent = Math.round(((totalMem - freeMem) / totalMem) * 100);
|
|
1123
|
+
if (usedPercent >= 90)
|
|
1124
|
+
return 'critical';
|
|
1125
|
+
if (usedPercent >= 75)
|
|
1126
|
+
return 'high';
|
|
1127
|
+
if (usedPercent >= 60)
|
|
1128
|
+
return 'moderate';
|
|
1129
|
+
return 'low';
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Count live sessions currently on the rerouted-interactive lane
|
|
1133
|
+
* (june15-headless-spawn-reroute PR2, findings S1/O2). The concurrency cap
|
|
1134
|
+
* (subscriptionPath.maxRerouted) bounds the held-REPL memory envelope ACROSS
|
|
1135
|
+
* all spawn classes — jobs, A2A, and mentor spawns alike — since A2A/mentor
|
|
1136
|
+
* spawns carry no jobSlug and would otherwise be bounded only by the global
|
|
1137
|
+
* maxSessions.
|
|
1138
|
+
*/
|
|
1139
|
+
countReroutedInteractiveSessions() {
|
|
1140
|
+
return this.listRunningSessions().filter((s) => s.launchLane === 'rerouted-interactive').length;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Decide whether a claude-code headless spawn should be rerouted onto the
|
|
1144
|
+
* interactive (subscription) lane (june15-headless-spawn-reroute PR2, "The
|
|
1145
|
+
* core switch"). ONLY claude-code is eligible — the lever is Anthropic-billing-
|
|
1146
|
+
* specific; codex/gemini headless paths don't draw the Anthropic SDK pot.
|
|
1147
|
+
*
|
|
1148
|
+
* - mode 'off'/absent → false (today's behavior, byte-for-byte)
|
|
1149
|
+
* - mode 'force' → true (zero `claude -p` traffic, the soak lever)
|
|
1150
|
+
* - mode 'auto' → consult the SAME shared decideSdkVsSubscription
|
|
1151
|
+
* + TTL-cached credit reader PR1 uses, so the two
|
|
1152
|
+
* routing layers can't drift. Subscription → reroute.
|
|
1153
|
+
*
|
|
1154
|
+
* Emits a transition-only log line when the resolved lane CHANGES for this
|
|
1155
|
+
* framework (mirrors PR1's onRoute — a line per change, not per spawn).
|
|
1156
|
+
*/
|
|
1157
|
+
async shouldRerouteToInteractive(framework) {
|
|
1158
|
+
if (framework !== 'claude-code')
|
|
1159
|
+
return false;
|
|
1160
|
+
const mode = this.config.subscriptionPathMode ?? 'off';
|
|
1161
|
+
if (mode === 'off')
|
|
1162
|
+
return false;
|
|
1163
|
+
let reroute;
|
|
1164
|
+
let reason;
|
|
1165
|
+
if (mode === 'force') {
|
|
1166
|
+
reroute = true;
|
|
1167
|
+
reason = 'mode=force';
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
// 'auto' — null snapshot (unknown / no reader) ⇒ subscription floor.
|
|
1171
|
+
let snapshot = null;
|
|
1172
|
+
if (this.readSdkCredit) {
|
|
1173
|
+
try {
|
|
1174
|
+
snapshot = await this.readSdkCredit();
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
1177
|
+
// @silent-fallback-ok — a credit-read error is the unknown state; the
|
|
1178
|
+
// shared decision below maps null → subscription floor (conservative),
|
|
1179
|
+
// exactly as PR1's routing policy does. No degradation: this is the
|
|
1180
|
+
// designed fallback, not an unexpected failure.
|
|
1181
|
+
snapshot = null;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const decision = decideSdkVsSubscription(snapshot, DEFAULT_SAFETY_MARGIN_FRACTION);
|
|
1185
|
+
reroute = decision.path === 'subscription';
|
|
1186
|
+
reason = `mode=auto ${decision.reason}`;
|
|
1187
|
+
}
|
|
1188
|
+
const lane = reroute ? 'rerouted-interactive' : 'headless';
|
|
1189
|
+
if (this.lastRerouteDecision.get(framework) !== lane) {
|
|
1190
|
+
this.lastRerouteDecision.set(framework, lane);
|
|
1191
|
+
console.log(`[SessionManager] reroute lane → ${lane} for ${framework} (${reason})`);
|
|
1192
|
+
}
|
|
1193
|
+
return reroute;
|
|
1194
|
+
}
|
|
1034
1195
|
/**
|
|
1035
1196
|
* Spawn a new Claude Code session in tmux.
|
|
1036
1197
|
*
|
|
@@ -1109,6 +1270,39 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1109
1270
|
// fallback model (separate quota bucket) instead of stalling. No-op +
|
|
1110
1271
|
// zero disk I/O unless codex.rateLimitModelSwap is enabled. Best-effort.
|
|
1111
1272
|
const launchModel = await this.resolveCodexLaunchModel(headlessFramework, options.model);
|
|
1273
|
+
// ── Headless-spawn reroute (june15-headless-spawn-reroute, PR2) ──
|
|
1274
|
+
// For claude-code ONLY, when subscriptionPath.mode is 'force' (or 'auto'
|
|
1275
|
+
// under SDK-pot pressure), reroute this `claude -p` one-shot onto the
|
|
1276
|
+
// interactive (subscription) lane so it stops billing the Agent SDK pot
|
|
1277
|
+
// after 2026-06-15. A control-flow fork, NOT a launch-spec swap (spec F3):
|
|
1278
|
+
// it delegates to the interactive delivery chain (ready-wait + inject) and
|
|
1279
|
+
// supplies its own completion sentinel + lifetime cap (the REPL never exits
|
|
1280
|
+
// on task success). Non-claude frameworks and 'off' fall straight through to
|
|
1281
|
+
// today's headless path, byte-for-byte.
|
|
1282
|
+
//
|
|
1283
|
+
// The cap + memory-pressure gate (spec O2/S1) is evaluated here: under
|
|
1284
|
+
// 'auto', an exceeded cap / elevated pressure DEGRADES to the headless lane
|
|
1285
|
+
// below (degradation-reported); under 'force' it REFUSES loudly (no headless
|
|
1286
|
+
// fallback — force guarantees zero `claude -p`). So this branch only commits
|
|
1287
|
+
// to the reroute when the gate passes; otherwise control falls through.
|
|
1288
|
+
if (await this.shouldRerouteToInteractive(headlessFramework)) {
|
|
1289
|
+
const gate = this.evaluateRerouteGate(options.name);
|
|
1290
|
+
if (gate.allow) {
|
|
1291
|
+
return await this.spawnReroutedInteractive({
|
|
1292
|
+
sessionId,
|
|
1293
|
+
tmuxSession,
|
|
1294
|
+
options,
|
|
1295
|
+
binaryPath: headlessBinaryPath,
|
|
1296
|
+
launchModel,
|
|
1297
|
+
resolvedCwd,
|
|
1298
|
+
workTreeFencingToken,
|
|
1299
|
+
shimDir,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
// gate refused — 'force' already threw inside evaluateRerouteGate; only
|
|
1303
|
+
// 'auto' reaches here, having degradation-reported. Fall through to the
|
|
1304
|
+
// headless build below.
|
|
1305
|
+
}
|
|
1112
1306
|
const headlessSpec = buildHeadlessLaunch(headlessFramework, {
|
|
1113
1307
|
binaryPath: headlessBinaryPath,
|
|
1114
1308
|
prompt: options.prompt,
|
|
@@ -1245,10 +1439,278 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1245
1439
|
framework: headlessFramework,
|
|
1246
1440
|
prompt: options.prompt,
|
|
1247
1441
|
maxDurationMinutes: options.maxDurationMinutes,
|
|
1442
|
+
// Positive-lane observability (june15-headless-spawn-reroute O4): always
|
|
1443
|
+
// populated now, so the soak's "zero headless under force" criterion is
|
|
1444
|
+
// machine-checkable from GET /sessions. completionMode stays 'exit' (the
|
|
1445
|
+
// headless one-shot signals completion by process exit — today's behavior).
|
|
1446
|
+
launchLane: 'headless',
|
|
1447
|
+
completionMode: 'exit',
|
|
1448
|
+
};
|
|
1449
|
+
this.state.saveSession(session);
|
|
1450
|
+
return session;
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Pre-spawn cap + memory-pressure gate for the reroute (june15-headless-spawn-
|
|
1454
|
+
* reroute PR2, findings O2/S1). Decides whether a rerouted-interactive spawn
|
|
1455
|
+
* may proceed:
|
|
1456
|
+
* - cap reached (>= subscriptionPath.maxRerouted) OR host memory pressure
|
|
1457
|
+
* elevated ('high'/'critical') → REFUSE.
|
|
1458
|
+
* - under 'force': a refusal THROWS loudly (force guarantees zero `claude -p`,
|
|
1459
|
+
* so there is no headless fallback) — and degradation-reports first.
|
|
1460
|
+
* - under 'auto': a refusal returns `{ allow: false }` after degradation-
|
|
1461
|
+
* reporting, so spawnSession falls through to the headless lane (the SDK
|
|
1462
|
+
* pot still works; the host stays healthy).
|
|
1463
|
+
* Returns `{ allow: true }` when the reroute may proceed.
|
|
1464
|
+
*/
|
|
1465
|
+
evaluateRerouteGate(spawnName) {
|
|
1466
|
+
const mode = this.config.subscriptionPathMode ?? 'off';
|
|
1467
|
+
const maxRerouted = this.config.subscriptionMaxRerouted ?? 3;
|
|
1468
|
+
const reroutedCount = this.countReroutedInteractiveSessions();
|
|
1469
|
+
const pressure = this.currentMemoryPressure();
|
|
1470
|
+
const pressureElevated = pressure === 'high' || pressure === 'critical';
|
|
1471
|
+
if (reroutedCount < maxRerouted && !pressureElevated) {
|
|
1472
|
+
return { allow: true };
|
|
1473
|
+
}
|
|
1474
|
+
const why = reroutedCount >= maxRerouted
|
|
1475
|
+
? `rerouted-session cap reached (${reroutedCount}/${maxRerouted})`
|
|
1476
|
+
: `host memory pressure is ${pressure}`;
|
|
1477
|
+
if (mode === 'force') {
|
|
1478
|
+
// Loud refusal — force-mode has no headless fallback. Report AND throw.
|
|
1479
|
+
DegradationReporter.getInstance().report({
|
|
1480
|
+
feature: 'SessionManager.spawnReroutedInteractive',
|
|
1481
|
+
primary: 'reroute claude-code one-shot onto the interactive subscription lane',
|
|
1482
|
+
fallback: 'spawn REFUSED (force-mode has no headless fallback)',
|
|
1483
|
+
reason: `Cannot reroute: ${why}`,
|
|
1484
|
+
impact: `Job/A2A spawn "${spawnName}" refused under force-mode to protect the host; it will retry on its next trigger`,
|
|
1485
|
+
});
|
|
1486
|
+
throw new Error(`Reroute refused (force-mode): ${why}`);
|
|
1487
|
+
}
|
|
1488
|
+
// 'auto' — degrade to the headless lane (host stays healthy). Caller falls
|
|
1489
|
+
// through to the headless build.
|
|
1490
|
+
DegradationReporter.getInstance().report({
|
|
1491
|
+
feature: 'SessionManager.spawnReroutedInteractive',
|
|
1492
|
+
primary: 'reroute claude-code one-shot onto the interactive subscription lane',
|
|
1493
|
+
fallback: 'fell back to the headless (claude -p) lane',
|
|
1494
|
+
reason: `Cannot reroute: ${why}`,
|
|
1495
|
+
impact: `Job/A2A spawn "${spawnName}" ran headless this time to avoid host pressure`,
|
|
1496
|
+
});
|
|
1497
|
+
this.recordRerouteFallback(why);
|
|
1498
|
+
return { allow: false };
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* F6 recurrence cap (spec "Structural guards" + Distrust Temporary
|
|
1502
|
+
* Success): the auto-mode headless fallback is self-healing — a reroute
|
|
1503
|
+
* that's BROKEN (cap stuck, pressure stuck high, continuity gap) would
|
|
1504
|
+
* silently fall back on EVERY spawn and look fine forever while the entire
|
|
1505
|
+
* June-15 reroute is dead. Count fallbacks in a rolling window; past the
|
|
1506
|
+
* cap, raise ONE escalated degradation event per window saying "dead, not
|
|
1507
|
+
* transiently degraded".
|
|
1508
|
+
*/
|
|
1509
|
+
rerouteFallbackTimes = [];
|
|
1510
|
+
rerouteFallbackEscalatedAt = 0;
|
|
1511
|
+
recordRerouteFallback(why) {
|
|
1512
|
+
const WINDOW_MS = 30 * 60_000;
|
|
1513
|
+
const CAP = 5;
|
|
1514
|
+
const now = Date.now();
|
|
1515
|
+
this.rerouteFallbackTimes = this.rerouteFallbackTimes.filter((t) => now - t < WINDOW_MS);
|
|
1516
|
+
this.rerouteFallbackTimes.push(now);
|
|
1517
|
+
if (this.rerouteFallbackTimes.length > CAP &&
|
|
1518
|
+
now - this.rerouteFallbackEscalatedAt >= WINDOW_MS) {
|
|
1519
|
+
this.rerouteFallbackEscalatedAt = now;
|
|
1520
|
+
DegradationReporter.getInstance().report({
|
|
1521
|
+
feature: 'SessionManager.rerouteFallbackRecurrence',
|
|
1522
|
+
primary: 'subscription-path reroute serving job/A2A spawns (mode auto)',
|
|
1523
|
+
fallback: `headless fallback recurred ${this.rerouteFallbackTimes.length}× in 30min — the reroute is effectively DEAD, not transiently degraded`,
|
|
1524
|
+
reason: `latest: ${why}`,
|
|
1525
|
+
impact: 'Every job/A2A spawn is back on the SDK-pot claude -p lane; post-June-15 this drains the pot with no reroute. Investigate the cap/pressure/continuity cause.',
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Spawn a claude-code job/A2A one-shot rerouted onto the interactive
|
|
1531
|
+
* (subscription) lane (june15-headless-spawn-reroute, PR2 "The core switch").
|
|
1532
|
+
*
|
|
1533
|
+
* This is the control-flow fork from spawnSession's headless branch. It:
|
|
1534
|
+
* - builds an INTERACTIVE claude launch (no `-p`; the REPL opens and waits)
|
|
1535
|
+
* via buildInteractiveLaunch, carrying the resolved model + the A2A
|
|
1536
|
+
* `--session-id`/`--resume` continuity flag through unchanged;
|
|
1537
|
+
* - splices the SAME `claudeHeadlessExtraFlags` (--allowedTools,
|
|
1538
|
+
* --strict-mcp-config --mcp-config {}) the headless path uses — both are
|
|
1539
|
+
* valid INTERACTIVE flags and the gate is framework-only, so the load-
|
|
1540
|
+
* bearing no-project-MCP boot (avoids the ~4.5-min OAuth-remote-MCP hang)
|
|
1541
|
+
* is preserved (spec F4/S3);
|
|
1542
|
+
* - creates the tmux session with the same env isolation as the headless
|
|
1543
|
+
* block PLUS `-x 200 -y 50` (prompt detection is tuned for the wide pane,
|
|
1544
|
+
* spec F6);
|
|
1545
|
+
* - enforces the memory-pressure + concurrency cap gate BEFORE rerouting
|
|
1546
|
+
* (spec O2/S1): under 'auto', exceeding either falls back to the headless
|
|
1547
|
+
* lane (degradation-reported); under 'force' the spawn is refused loudly;
|
|
1548
|
+
* - appends a deterministic completion sentinel to the prompt and stamps the
|
|
1549
|
+
* session with completionMode='pattern' + the per-session pattern + a hard
|
|
1550
|
+
* maxLifetimeMinutes, so the monitor loop can reap a never-exiting REPL on
|
|
1551
|
+
* sentinel-match or lifetime-expiry (spec O1, "the crux");
|
|
1552
|
+
* - fires the ready-wait + inject ASYNCHRONOUSLY (does NOT block the return —
|
|
1553
|
+
* callers expect the Session back promptly), reusing the same
|
|
1554
|
+
* waitForClaudeReadyWithRetry + injectMessage machinery handleReadyAndInject
|
|
1555
|
+
* uses.
|
|
1556
|
+
*/
|
|
1557
|
+
async spawnReroutedInteractive(args) {
|
|
1558
|
+
const { sessionId, tmuxSession, options, binaryPath, launchModel, resolvedCwd, workTreeFencingToken, shimDir } = args;
|
|
1559
|
+
const lifetimeMinutes = this.config.subscriptionReroutedLifetimeMinutes ?? 45;
|
|
1560
|
+
// ── Completion sentinel (spec O1, "the crux") ──
|
|
1561
|
+
// An interactive REPL never exits on task success, and the default
|
|
1562
|
+
// completionPatterns are session-DEATH phrases. So we instruct the model to
|
|
1563
|
+
// print a deterministic, per-session sentinel as its FINAL line, and teach
|
|
1564
|
+
// the monitor loop to reap on that (completionMode='pattern'). The sentinel
|
|
1565
|
+
// is keyed on the session id suffix so two concurrent rerouted sessions
|
|
1566
|
+
// can't false-trigger each other.
|
|
1567
|
+
const sentinel = `INSTAR_JOB_COMPLETE_${sessionId.slice(-8)}`;
|
|
1568
|
+
const promptWithSentinel = `${options.prompt}\n\nWhen you have fully completed this task, print exactly this marker as your final line: ${sentinel}`;
|
|
1569
|
+
// ── Build the INTERACTIVE launch (no `-p`) ──
|
|
1570
|
+
// Carry the resolved model + the A2A continuity flag through. sessionId
|
|
1571
|
+
// (--session-id) pins the transcript at the caller-chosen uuid so A2A
|
|
1572
|
+
// `--resume` keeps working (spec "A2A cold spawns"); resumeSessionId
|
|
1573
|
+
// (--resume) reloads a prior transcript. Mirrors the headless precedence
|
|
1574
|
+
// (sessionId wins).
|
|
1575
|
+
const launchSpec = buildInteractiveLaunch('claude-code', {
|
|
1576
|
+
binaryPath,
|
|
1577
|
+
...(launchModel ? { defaultModel: launchModel } : {}),
|
|
1578
|
+
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
|
1579
|
+
...(!options.sessionId && options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}),
|
|
1580
|
+
});
|
|
1581
|
+
// Splice the load-bearing headless flags into the INTERACTIVE argv. Both
|
|
1582
|
+
// --allowedTools and --strict-mcp-config --mcp-config {} are valid
|
|
1583
|
+
// interactive flags; claudeHeadlessExtraFlags is gated only on
|
|
1584
|
+
// framework==='claude-code', so the reroute calls it and appends to the end
|
|
1585
|
+
// of the argv (the interactive argv has no `-p` positional to splice before).
|
|
1586
|
+
// Without --strict-mcp-config, an MCP-restricted rerouted job re-introduces
|
|
1587
|
+
// the documented ~4.5-min OAuth-remote-MCP boot hang (spec F4/S3).
|
|
1588
|
+
const extraClaudeFlags = claudeHeadlessExtraFlags({
|
|
1589
|
+
framework: 'claude-code',
|
|
1590
|
+
allowedTools: options.allowedTools,
|
|
1591
|
+
disableProjectMcp: options.disableProjectMcp,
|
|
1592
|
+
});
|
|
1593
|
+
if (extraClaudeFlags.length > 0) {
|
|
1594
|
+
launchSpec.argv.push(...extraClaudeFlags);
|
|
1595
|
+
}
|
|
1596
|
+
// K9: build PATH env with shim prepended (when shim was installed) — same as
|
|
1597
|
+
// the headless block.
|
|
1598
|
+
const inheritedPath = process.env.PATH ?? '';
|
|
1599
|
+
const shimmedPath = shimDir ? `${shimDir}:${inheritedPath}` : inheritedPath;
|
|
1600
|
+
const frameworkEnvFlags = [];
|
|
1601
|
+
for (const [k, v] of Object.entries(launchSpec.envOverrides)) {
|
|
1602
|
+
frameworkEnvFlags.push('-e', `${k}=${v}`);
|
|
1603
|
+
}
|
|
1604
|
+
// Anthropic-only env (claude-code reroute is always claude-code).
|
|
1605
|
+
const anthropicEnvFlags = (this.config.anthropicApiKey ?? '').startsWith('sk-ant-oat')
|
|
1606
|
+
? ['-e', `CLAUDE_CODE_OAUTH_TOKEN=${this.config.anthropicApiKey}`, '-e', 'ANTHROPIC_API_KEY=']
|
|
1607
|
+
: ['-e', `ANTHROPIC_API_KEY=${this.config.anthropicApiKey ?? ''}`, '-e', 'CLAUDE_CODE_OAUTH_TOKEN='];
|
|
1608
|
+
anthropicEnvFlags.push('-e', `ANTHROPIC_BASE_URL=${this.config.anthropicBaseUrl ?? ''}`);
|
|
1609
|
+
try {
|
|
1610
|
+
execFileSync(this.config.tmuxPath, [
|
|
1611
|
+
'new-session', '-d',
|
|
1612
|
+
'-s', tmuxSession,
|
|
1613
|
+
'-c', resolvedCwd,
|
|
1614
|
+
// Wide pane — detectClaudePrompt reads the last lines for the ❯/status
|
|
1615
|
+
// markers and is tuned for the interactive default (spec F6). The 80×24
|
|
1616
|
+
// headless default makes ready/idle detection flaky.
|
|
1617
|
+
'-x', '200', '-y', '50',
|
|
1618
|
+
...frameworkEnvFlags,
|
|
1619
|
+
...(this.config.claudeCodeMaxRetries != null
|
|
1620
|
+
? ['-e', `CLAUDE_CODE_MAX_RETRIES=${this.config.claudeCodeMaxRetries}`]
|
|
1621
|
+
: []),
|
|
1622
|
+
'-e', `INSTAR_SESSION_ID=${sessionId}`,
|
|
1623
|
+
'-e', `INSTAR_SESSION_NAME=${tmuxSession}`,
|
|
1624
|
+
'-e', `INSTAR_SERVER_URL=http://localhost:${this.config.port}`,
|
|
1625
|
+
'-e', `INSTAR_AUTH_TOKEN=${this.config.authToken}`,
|
|
1626
|
+
'-e', `INSTAR_AGENT_ID=${this.config.projectName}`,
|
|
1627
|
+
...(workTreeFencingToken ? ['-e', `INSTAR_FENCING_TOKEN=${workTreeFencingToken}`] : []),
|
|
1628
|
+
...(workTreeFencingToken ? ['-e', `INSTAR_WORKTREE_PATH=${resolvedCwd}`] : []),
|
|
1629
|
+
...(shimDir ? ['-e', `PATH=${shimmedPath}`, '-e', `BASH_ENV=${path.join(shimDir, '.shellrc')}`] : []),
|
|
1630
|
+
...anthropicEnvFlags,
|
|
1631
|
+
// Same DB-credential isolation as every other spawn (Portal 2026-02-22).
|
|
1632
|
+
'-e', 'DATABASE_URL=',
|
|
1633
|
+
'-e', 'DIRECT_DATABASE_URL=',
|
|
1634
|
+
'-e', 'DATABASE_URL_PROD=',
|
|
1635
|
+
'-e', 'DATABASE_URL_DEV=',
|
|
1636
|
+
'-e', 'DATABASE_URL_TEST=',
|
|
1637
|
+
...launchSpec.argv,
|
|
1638
|
+
], { encoding: 'utf-8' });
|
|
1639
|
+
try {
|
|
1640
|
+
execFileSync(this.config.tmuxPath, [
|
|
1641
|
+
'set-option', '-t', `=${tmuxSession}:`, 'history-limit', '50000',
|
|
1642
|
+
], { encoding: 'utf-8', timeout: 5000 });
|
|
1643
|
+
}
|
|
1644
|
+
catch {
|
|
1645
|
+
// @silent-fallback-ok — history-limit is a nice-to-have
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
catch (err) {
|
|
1649
|
+
throw new Error(`Failed to create rerouted interactive tmux session: ${err}`);
|
|
1650
|
+
}
|
|
1651
|
+
const session = {
|
|
1652
|
+
id: sessionId,
|
|
1653
|
+
name: options.name,
|
|
1654
|
+
status: 'running',
|
|
1655
|
+
jobSlug: options.jobSlug,
|
|
1656
|
+
tmuxSession,
|
|
1657
|
+
startedAt: new Date().toISOString(),
|
|
1658
|
+
triggeredBy: options.triggeredBy,
|
|
1659
|
+
model: resolveModelForFramework('claude-code', launchModel) ?? launchModel,
|
|
1660
|
+
framework: 'claude-code',
|
|
1661
|
+
prompt: options.prompt,
|
|
1662
|
+
maxDurationMinutes: options.maxDurationMinutes,
|
|
1663
|
+
// The reroute's completion contract (spec O1 + O4):
|
|
1664
|
+
launchLane: 'rerouted-interactive',
|
|
1665
|
+
completionMode: 'pattern',
|
|
1666
|
+
completionPatterns: [sentinel],
|
|
1667
|
+
maxLifetimeMinutes: lifetimeMinutes,
|
|
1248
1668
|
};
|
|
1249
1669
|
this.state.saveSession(session);
|
|
1670
|
+
// Deliver the prompt asynchronously — do NOT block spawnSession's return on
|
|
1671
|
+
// readiness (callers expect the Session back promptly; the ready-gate is
|
|
1672
|
+
// ~90s worst-case). Reuses the EXISTING ready-wait + inject machinery.
|
|
1673
|
+
const readyTimeout = options.resumeSessionId ? 120_000 : 90_000;
|
|
1674
|
+
this.injectAfterReady(tmuxSession, promptWithSentinel, readyTimeout).catch((err) => {
|
|
1675
|
+
// @silent-fallback-ok — fire-and-forget error log for the background
|
|
1676
|
+
// ready-wait+inject (mirrors handleReadyAndInject's .catch). injectAfterReady
|
|
1677
|
+
// already DegradationReporter-reports a genuine delivery failure internally;
|
|
1678
|
+
// this guards only against an unexpected rejection escaping the detached promise.
|
|
1679
|
+
console.error(`[SessionManager] Error during rerouted ready-and-inject for "${tmuxSession}": ${err}`);
|
|
1680
|
+
});
|
|
1250
1681
|
return session;
|
|
1251
1682
|
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Wait for Claude to be ready, then inject a prompt — the minimal ready-wait
|
|
1685
|
+
* + inject path shared by the rerouted-interactive spawn
|
|
1686
|
+
* (june15-headless-spawn-reroute). Factored from handleReadyAndInject's shape;
|
|
1687
|
+
* deliberately WITHOUT the resume-fresh-spawn fallback (a rerouted job's
|
|
1688
|
+
* delivery is best-effort — the monitor loop's lifetime cap bounds a dead
|
|
1689
|
+
* REPL, and the job reruns cleanly on its next trigger).
|
|
1690
|
+
*/
|
|
1691
|
+
async injectAfterReady(tmuxSession, prompt, readyTimeout) {
|
|
1692
|
+
const ready = await this.waitForClaudeReadyWithRetry(tmuxSession, readyTimeout);
|
|
1693
|
+
if (ready) {
|
|
1694
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1695
|
+
this.injectMessage(tmuxSession, prompt);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
// Not ready in time. If tmux is still alive, best-effort inject anyway
|
|
1699
|
+
// (prompt-detection false negative). If it's gone, the spawn crashed during
|
|
1700
|
+
// startup — surface a degradation; the monitor loop will not see a sentinel
|
|
1701
|
+
// and the lifetime cap will reap the record.
|
|
1702
|
+
if (this.tmuxSessionExists(tmuxSession)) {
|
|
1703
|
+
this.injectMessage(tmuxSession, prompt);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
DegradationReporter.getInstance().report({
|
|
1707
|
+
feature: 'SessionManager.injectAfterReady',
|
|
1708
|
+
primary: 'wait for rerouted interactive Claude ready, inject prompt',
|
|
1709
|
+
fallback: 'tmux died during startup; prompt not delivered',
|
|
1710
|
+
reason: 'rerouted spawn crashed during startup; readiness probe could not verify prompt',
|
|
1711
|
+
impact: 'Rerouted job prompt dropped; the job reruns on its next trigger',
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1252
1714
|
/**
|
|
1253
1715
|
* Check if a session is still running by checking tmux AND verifying
|
|
1254
1716
|
* that the Claude process is running inside (not a zombie tmux pane).
|
|
@@ -1768,6 +2230,25 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1768
2230
|
else
|
|
1769
2231
|
skipped++; // not-lease-holder / protected — recorded, kept for next awake tick
|
|
1770
2232
|
}
|
|
2233
|
+
else if (session.launchLane === 'rerouted-interactive' && session.jobSlug) {
|
|
2234
|
+
// Boot reconciliation (june15-headless-spawn-reroute, PR2 finding O3):
|
|
2235
|
+
// a rerouted JOB session that survived a restart is still ALIVE in tmux,
|
|
2236
|
+
// but JobScheduler.activeRunIds (in-memory) is lost AND triggerJob has no
|
|
2237
|
+
// guard against re-triggering the slug on its next cron tick → double
|
|
2238
|
+
// execution + double billing. Adopt-or-kill: we KILL (the safe choice —
|
|
2239
|
+
// the job reruns cleanly with fresh run-tracking; adopting an orphan REPL
|
|
2240
|
+
// whose run-id we no longer hold would silently never finalize). Operator
|
|
2241
|
+
// kill so the lease/KEEP-guards don't pin the orphan.
|
|
2242
|
+
const r = await this.terminateSession(session.id, 'boot-reconcile-rerouted-job', {
|
|
2243
|
+
finalStatus: 'killed',
|
|
2244
|
+
disposition: 'terminal',
|
|
2245
|
+
origin: 'operator',
|
|
2246
|
+
});
|
|
2247
|
+
if (r.terminated) {
|
|
2248
|
+
console.log(`[SessionManager] Startup reconcile: killed surviving rerouted job session "${session.name}" (slug ${session.jobSlug}) — the job reruns cleanly on its next trigger.`);
|
|
2249
|
+
}
|
|
2250
|
+
kept++; // counted as "not dead-purged"; the kill is the reconciliation
|
|
2251
|
+
}
|
|
1771
2252
|
else {
|
|
1772
2253
|
kept++;
|
|
1773
2254
|
if (v?.liveness === 'indeterminate')
|
|
@@ -1827,15 +2308,9 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1827
2308
|
const usedPercent = Math.round(((totalMem - freeMem) / totalMem) * 100);
|
|
1828
2309
|
const freeMemMB = Math.round(freeMem / 1048576);
|
|
1829
2310
|
const totalMemMB = Math.round(totalMem / 1048576);
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
else if (usedPercent >= 75)
|
|
1834
|
-
memoryPressure = 'high';
|
|
1835
|
-
else if (usedPercent >= 60)
|
|
1836
|
-
memoryPressure = 'moderate';
|
|
1837
|
-
else
|
|
1838
|
-
memoryPressure = 'low';
|
|
2311
|
+
// Shared with the reroute pre-spawn gate (june15-headless-spawn-reroute O2)
|
|
2312
|
+
// via currentMemoryPressure() — one definition of the pressure tiers.
|
|
2313
|
+
const memoryPressure = this.currentMemoryPressure();
|
|
1839
2314
|
// Build actionable suggestions
|
|
1840
2315
|
const suggestions = [];
|
|
1841
2316
|
if (staleSessions.length > 0) {
|
|
@@ -1875,6 +2350,25 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1875
2350
|
return false;
|
|
1876
2351
|
return this.config.completionPatterns.some(pattern => output.includes(pattern));
|
|
1877
2352
|
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Per-session completion detection (june15-headless-spawn-reroute, PR2).
|
|
2355
|
+
* Scans the captured pane for THIS session's own `completionPatterns` (the
|
|
2356
|
+
* injected sentinel) rather than the global death-phrase config. A rerouted-
|
|
2357
|
+
* interactive REPL never exits and never prints the global death phrases on
|
|
2358
|
+
* success, so the global detectCompletion would never reap it. Used by the
|
|
2359
|
+
* monitor loop ONLY for completionMode==='pattern' sessions; the default lane
|
|
2360
|
+
* is byte-for-byte untouched. Returns false when the session has no
|
|
2361
|
+
* per-session patterns (defensive — should not happen for a 'pattern' session).
|
|
2362
|
+
*/
|
|
2363
|
+
detectSessionCompletion(session) {
|
|
2364
|
+
const patterns = session.completionPatterns;
|
|
2365
|
+
if (!patterns || patterns.length === 0)
|
|
2366
|
+
return false;
|
|
2367
|
+
const output = this.captureOutput(session.tmuxSession, 30);
|
|
2368
|
+
if (!output)
|
|
2369
|
+
return false;
|
|
2370
|
+
return patterns.some((pattern) => output.includes(pattern));
|
|
2371
|
+
}
|
|
1878
2372
|
/**
|
|
1879
2373
|
* Reap completed/zombie sessions.
|
|
1880
2374
|
*/
|
|
@@ -2040,7 +2534,15 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
2040
2534
|
envFramework: frameworkFromEnv(),
|
|
2041
2535
|
});
|
|
2042
2536
|
const binaryPath = this.config.frameworkBinaryPaths?.[framework]
|
|
2043
|
-
??
|
|
2537
|
+
?? (framework === 'pi-cli'
|
|
2538
|
+
// pi-cli must NEVER inherit the claudePath fallback: claudePath holds a
|
|
2539
|
+
// CLAUDE binary on claude-code installs, and silently launching Claude
|
|
2540
|
+
// with pi's flags violates the additive-only constraint
|
|
2541
|
+
// (PI-HARNESS-INTEGRATION-SPEC §0.1). A bare 'pi' lets PATH resolution
|
|
2542
|
+
// work when detection missed an install; a missing binary then fails
|
|
2543
|
+
// loudly inside the pane instead of impersonating another framework.
|
|
2544
|
+
? 'pi'
|
|
2545
|
+
: this.config.claudePath);
|
|
2044
2546
|
if (!binaryPath) {
|
|
2045
2547
|
throw new Error(`No binary path available for framework "${framework}"`);
|
|
2046
2548
|
}
|
|
@@ -2061,6 +2563,12 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
2061
2563
|
...(options?.codexLocalProvider ? { codexLocalProvider: options.codexLocalProvider } : {}),
|
|
2062
2564
|
// Per-agent codex threadline MCP override (ignored by non-codex builders).
|
|
2063
2565
|
...(this.config.codexThreadlineMcp ? { codexThreadlineMcp: this.config.codexThreadlineMcp } : {}),
|
|
2566
|
+
// pi-cli: pin session transcripts into the agent state dir so they are
|
|
2567
|
+
// durable + discoverable (PI-HARNESS-INTEGRATION-SPEC §2.2). Ignored by
|
|
2568
|
+
// every other framework's builder.
|
|
2569
|
+
...(framework === 'pi-cli'
|
|
2570
|
+
? { piSessionDir: path.join(this.config.projectDir, '.instar', 'state', 'pi-sessions') }
|
|
2571
|
+
: {}),
|
|
2064
2572
|
});
|
|
2065
2573
|
// Spawn the framework CLI in tmux — no bash -c shell intermediary.
|
|
2066
2574
|
// Uses tmux -e flags to set/unset env vars directly, matching spawnSession pattern.
|
|
@@ -2764,6 +3272,13 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
2764
3272
|
* Used by injectMessage after provenance checks pass.
|
|
2765
3273
|
*/
|
|
2766
3274
|
rawInject(tmuxSession, text) {
|
|
3275
|
+
// Strip any EMBEDDED bracketed-paste markers from the prompt content before
|
|
3276
|
+
// we wrap it in our own \x1b[200~…\x1b[201~ (june15-headless-spawn-reroute,
|
|
3277
|
+
// PR2 finding S2). A job/A2A prompt containing a literal \x1b[201~ would
|
|
3278
|
+
// otherwise forge a paste boundary and submit extra REPL turns — the
|
|
3279
|
+
// headless `-p` argv path was immune, and InputGuard only scans topic-bound
|
|
3280
|
+
// sessions, so rerouted job/A2A prompts need this sanitizer at the chokepoint.
|
|
3281
|
+
text = text.replace(/\x1b\[20[01]~/g, '');
|
|
2767
3282
|
// Reset idle-prompt timer — this session is about to receive new input,
|
|
2768
3283
|
// so it's not a zombie. Without this, the zombie detector can kill a session
|
|
2769
3284
|
// that just received a message but hasn't produced output yet.
|
|
@@ -2990,6 +3505,26 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
2990
3505
|
if (hasPromptChar && (line.includes(marker) || (lines[i + 1] && lines[i + 1].includes(shortMarker)))) {
|
|
2991
3506
|
return true;
|
|
2992
3507
|
}
|
|
3508
|
+
// pi has NO prompt char at all — its input box is a bare text line
|
|
3509
|
+
// SANDWICHED between two horizontal-rule lines (verified by live pane
|
|
3510
|
+
// capture, P0.1 eval pi 0.78.1):
|
|
3511
|
+
// ────────────────────────────
|
|
3512
|
+
// STUCK-MARKER-TEXT …
|
|
3513
|
+
// ────────────────────────────
|
|
3514
|
+
// Submitted transcript text is never between two rules, so requiring a
|
|
3515
|
+
// rule within 2 lines BOTH above and below (the tolerance covers a long
|
|
3516
|
+
// input wrapping to a second row) plus the marker match cannot
|
|
3517
|
+
// false-fire on normal output. Without this branch, an injected message
|
|
3518
|
+
// stranded in pi's input box would never be detected as stuck and the
|
|
3519
|
+
// Enter-recovery would never fire (the Gemini lesson, same shape).
|
|
3520
|
+
if (line.includes(marker) || (lines[i + 1] && lines[i + 1].includes(shortMarker))) {
|
|
3521
|
+
const isRule = (l) => !!l && /^\s*─{10,}\s*$/.test(l);
|
|
3522
|
+
const ruleAbove = isRule(lines[i - 1]) || isRule(lines[i - 2]);
|
|
3523
|
+
const ruleBelow = isRule(lines[i + 1]) || isRule(lines[i + 2]);
|
|
3524
|
+
if (ruleAbove && ruleBelow) {
|
|
3525
|
+
return true;
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
2993
3528
|
}
|
|
2994
3529
|
return false;
|
|
2995
3530
|
}
|