crewly 1.20.176 → 1.20.177
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/config/skills/orchestrator/update-team-member/SKILL.md +1 -1
- package/dist/backend/backend/src/constants.d.ts +58 -3
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +72 -3
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +2 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.d.ts +30 -0
- package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.js +100 -0
- package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.controller.js +33 -4
- package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +25 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/routes/modules/system.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/modules/system.routes.js +3 -0
- package/dist/backend/backend/src/routes/modules/system.routes.js.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-heartbeat-monitor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-heartbeat-monitor.service.js +6 -0
- package/dist/backend/backend/src/services/agent/agent-heartbeat-monitor.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts +10 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +103 -7
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/antigravity-runtime.service.d.ts +1 -1
- package/dist/backend/backend/src/services/agent/antigravity-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/antigravity-runtime.service.js +3 -2
- package/dist/backend/backend/src/services/agent/antigravity-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +7 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +16 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/idle-detection.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/idle-detection.service.js +2 -1
- package/dist/backend/backend/src/services/agent/idle-detection.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.js +7 -0
- package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/tmux.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/tmux.service.js +2 -1
- package/dist/backend/backend/src/services/agent/tmux.service.js.map +1 -1
- package/dist/backend/backend/src/services/harness/harness-status.service.d.ts +17 -0
- package/dist/backend/backend/src/services/harness/harness-status.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/harness/harness-status.service.js +25 -1
- package/dist/backend/backend/src/services/harness/harness-status.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.js +2 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.js.map +1 -1
- package/dist/backend/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js +3 -2
- package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/orchestrator-status.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/orchestrator-status.service.js +3 -2
- package/dist/backend/backend/src/services/orchestrator/orchestrator-status.service.js.map +1 -1
- package/dist/backend/backend/src/services/runtime-fallback/effective-runtime.d.ts +100 -0
- package/dist/backend/backend/src/services/runtime-fallback/effective-runtime.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/effective-runtime.js +115 -0
- package/dist/backend/backend/src/services/runtime-fallback/effective-runtime.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/reset-time.d.ts +66 -0
- package/dist/backend/backend/src/services/runtime-fallback/reset-time.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/reset-time.js +211 -0
- package/dist/backend/backend/src/services/runtime-fallback/reset-time.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.d.ts +39 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.js +56 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.d.ts +377 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.js +867 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.store.d.ts +53 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.store.js +107 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.store.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.types.d.ts +126 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.types.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.types.js +152 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.types.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.d.ts +82 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.js +219 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.d.ts +203 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.js +419 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-usage-probe.d.ts +54 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-usage-probe.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-usage-probe.js +80 -0
- package/dist/backend/backend/src/services/runtime-fallback/runtime-usage-probe.js.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/usage-limit-rules.d.ts +99 -0
- package/dist/backend/backend/src/services/runtime-fallback/usage-limit-rules.d.ts.map +1 -0
- package/dist/backend/backend/src/services/runtime-fallback/usage-limit-rules.js +240 -0
- package/dist/backend/backend/src/services/runtime-fallback/usage-limit-rules.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +8 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +16 -2
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/workflow/message-scheduler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/workflow/message-scheduler.service.js +3 -2
- package/dist/backend/backend/src/services/workflow/message-scheduler.service.js.map +1 -1
- package/dist/backend/backend/src/services/workflow/scheduler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/workflow/scheduler.service.js +3 -2
- package/dist/backend/backend/src/services/workflow/scheduler.service.js.map +1 -1
- package/dist/backend/backend/src/types/index.d.ts +14 -0
- package/dist/backend/backend/src/types/index.d.ts.map +1 -1
- package/dist/backend/backend/src/types/index.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +58 -3
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +72 -3
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts +10 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +103 -7
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/antigravity-runtime.service.d.ts +1 -1
- package/dist/cli/backend/src/services/agent/antigravity-runtime.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/antigravity-runtime.service.js +3 -2
- package/dist/cli/backend/src/services/agent/antigravity-runtime.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +7 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +16 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.js +7 -0
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.js.map +1 -1
- package/dist/cli/backend/src/services/harness/harness-status.service.d.ts +17 -0
- package/dist/cli/backend/src/services/harness/harness-status.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/harness/harness-status.service.js +25 -1
- package/dist/cli/backend/src/services/harness/harness-status.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js +2 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js.map +1 -1
- package/dist/cli/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js +3 -2
- package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js.map +1 -1
- package/dist/cli/backend/src/services/orchestrator/orchestrator-status.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/orchestrator/orchestrator-status.service.js +3 -2
- package/dist/cli/backend/src/services/orchestrator/orchestrator-status.service.js.map +1 -1
- package/dist/cli/backend/src/services/runtime-fallback/effective-runtime.d.ts +100 -0
- package/dist/cli/backend/src/services/runtime-fallback/effective-runtime.d.ts.map +1 -0
- package/dist/cli/backend/src/services/runtime-fallback/effective-runtime.js +115 -0
- package/dist/cli/backend/src/services/runtime-fallback/effective-runtime.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +8 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +16 -2
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/types/index.d.ts +14 -0
- package/dist/cli/backend/src/types/index.d.ts.map +1 -1
- package/dist/cli/backend/src/types/index.js.map +1 -1
- package/dist/cli/cli/src/utils/runtime-auth.d.ts +1 -1
- package/dist/cli/cli/src/utils/runtime-auth.js +1 -1
- package/frontend/dist/assets/{index-0726afab.js → index-69af46c3.js} +275 -275
- package/frontend/dist/assets/{index-0c5449b5.css → index-cdc52122.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime fallback — when an agent's runtime runs out of usage, run the agent
|
|
3
|
+
* on the next runtime of its fallback chain until the limit resets, then
|
|
4
|
+
* switch it back at an idle boundary.
|
|
5
|
+
*
|
|
6
|
+
* Flow (specs/2026-10-01-runtime-fallback.md):
|
|
7
|
+
*
|
|
8
|
+
* 1. **Detect** ({@link RuntimeFallbackService.reportOutput}): live output or
|
|
9
|
+
* an in-process run error matched a usage-limit rule. Transient rate limits
|
|
10
|
+
* are left to the runtime's own retries unless they keep coming.
|
|
11
|
+
* 2. **Confirm** with the runtime's probe (Claude: a one-line print-mode turn);
|
|
12
|
+
* a probe that answers normally marks the match a false positive.
|
|
13
|
+
* 3. **Mark the runtime exhausted** (account-wide) with its reset time.
|
|
14
|
+
* 4. **Switch** the agent that hit it at its next safe point: handover file,
|
|
15
|
+
* `runtimeOverride` (the configured runtime is not changed), relaunch, then
|
|
16
|
+
* re-deliver what waited for it.
|
|
17
|
+
* 5. Other agents on that runtime switch **when they next get work**
|
|
18
|
+
* ({@link RuntimeFallbackService.beforeDelivery}) or when started
|
|
19
|
+
* ({@link RuntimeFallbackService.resolveLaunch}); idle ones are not woken.
|
|
20
|
+
* 6. **Switch back** ({@link RuntimeFallbackService.tick}): at the reset time
|
|
21
|
+
* or by a periodic probe; each agent reverts at its next idle boundary.
|
|
22
|
+
* 7. **Tell the owner once** per event, and once when it is over.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here throws into its callers: delivery and launch fall back to the
|
|
25
|
+
* configured runtime on any error.
|
|
26
|
+
*
|
|
27
|
+
* @module services/runtime-fallback/runtime-fallback.service
|
|
28
|
+
*/
|
|
29
|
+
import { ORCHESTRATOR_SESSION_NAME, RUNTIME_FALLBACK_CONSTANTS, RUNTIME_TYPES } from '../../constants.js';
|
|
30
|
+
import { detectUsageLimit } from './usage-limit-rules.js';
|
|
31
|
+
import { applySettingsPatch, runtimeLabel, runtimeShortLabel, } from './runtime-fallback.types.js';
|
|
32
|
+
const C = RUNTIME_FALLBACK_CONSTANTS;
|
|
33
|
+
const SILENT = { info: () => undefined, warn: () => undefined, debug: () => undefined };
|
|
34
|
+
/**
|
|
35
|
+
* "1 agent" / "3 agents".
|
|
36
|
+
*
|
|
37
|
+
* @param n - Count
|
|
38
|
+
* @returns Phrase
|
|
39
|
+
*/
|
|
40
|
+
function agents(n) {
|
|
41
|
+
return `${n} agent${n === 1 ? '' : 's'}`;
|
|
42
|
+
}
|
|
43
|
+
/** Runtime fallback coordinator. */
|
|
44
|
+
export class RuntimeFallbackService {
|
|
45
|
+
deps;
|
|
46
|
+
state;
|
|
47
|
+
logger;
|
|
48
|
+
now;
|
|
49
|
+
sleep;
|
|
50
|
+
/** Sessions being switched (messages for them are queued) */
|
|
51
|
+
switching = new Set();
|
|
52
|
+
/** Runtimes whose detection is being confirmed by a probe */
|
|
53
|
+
confirming = new Map();
|
|
54
|
+
/** A probe said the runtime is fine: ignore matches until then */
|
|
55
|
+
mutedUntil = new Map();
|
|
56
|
+
/** Recent transient rate limits per session */
|
|
57
|
+
transients = new Map();
|
|
58
|
+
/** Owner notice due time per exhausted runtime */
|
|
59
|
+
noticeDue = new Map();
|
|
60
|
+
/** Kickoff notes waiting for a session's next launch */
|
|
61
|
+
kickoffNotes = new Map();
|
|
62
|
+
availability = null;
|
|
63
|
+
timer = null;
|
|
64
|
+
ticking = false;
|
|
65
|
+
/**
|
|
66
|
+
* @param deps - Dependencies
|
|
67
|
+
*/
|
|
68
|
+
constructor(deps) {
|
|
69
|
+
this.deps = deps;
|
|
70
|
+
this.logger = deps.logger ?? SILENT;
|
|
71
|
+
this.now = deps.now ?? (() => Date.now());
|
|
72
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
73
|
+
this.state = deps.store.load();
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------- lifecycle
|
|
76
|
+
/** Start the periodic tick (switch-back checks, reverts, notices). */
|
|
77
|
+
start() {
|
|
78
|
+
if (this.timer)
|
|
79
|
+
return;
|
|
80
|
+
this.timer = setInterval(() => void this.tick(), C.TICK_MS);
|
|
81
|
+
this.timer.unref?.();
|
|
82
|
+
// Notices that were due before a restart, and a first availability read.
|
|
83
|
+
void this.tick();
|
|
84
|
+
}
|
|
85
|
+
/** Stop the tick. */
|
|
86
|
+
stop() {
|
|
87
|
+
if (this.timer)
|
|
88
|
+
clearInterval(this.timer);
|
|
89
|
+
this.timer = null;
|
|
90
|
+
}
|
|
91
|
+
// ----------------------------------------------------------------- settings
|
|
92
|
+
/** @returns Current settings (copy) */
|
|
93
|
+
getSettings() {
|
|
94
|
+
return JSON.parse(JSON.stringify(this.state.settings));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Apply a partial settings update.
|
|
98
|
+
*
|
|
99
|
+
* @param patch - Owner's update (validated)
|
|
100
|
+
* @returns New settings
|
|
101
|
+
* @throws RuntimeFallbackSettingsError when invalid
|
|
102
|
+
*/
|
|
103
|
+
updateSettings(patch) {
|
|
104
|
+
this.state.settings = applySettingsPatch(this.state.settings, patch);
|
|
105
|
+
this.availability = null;
|
|
106
|
+
this.save();
|
|
107
|
+
return this.getSettings();
|
|
108
|
+
}
|
|
109
|
+
// ------------------------------------------------------------------- hooks
|
|
110
|
+
/**
|
|
111
|
+
* Runtime a session runs on instead of its configured one.
|
|
112
|
+
*
|
|
113
|
+
* @param sessionName - Session
|
|
114
|
+
* @returns Fallback runtime, or null
|
|
115
|
+
*/
|
|
116
|
+
overrideFor(sessionName) {
|
|
117
|
+
return this.state.overrides[sessionName]?.runtime ?? null;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Take the note for a session's next kickoff.
|
|
121
|
+
*
|
|
122
|
+
* @param sessionName - Session
|
|
123
|
+
* @returns Note (once) or null
|
|
124
|
+
*/
|
|
125
|
+
takeKickoffNote(sessionName) {
|
|
126
|
+
const note = this.kickoffNotes.get(sessionName) ?? null;
|
|
127
|
+
this.kickoffNotes.delete(sessionName);
|
|
128
|
+
return note;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Runtime output / error of a session. A usage limit starts the fallback
|
|
132
|
+
* (asynchronously); everything else is ignored.
|
|
133
|
+
*
|
|
134
|
+
* @param sessionName - Session
|
|
135
|
+
* @param runtime - Runtime it runs on
|
|
136
|
+
* @param text - Output (never logged)
|
|
137
|
+
* @param source - `output` (live chunk), `screen` (sweep, ignored: old scrollback) or `error`
|
|
138
|
+
* @returns True when a usage limit was recognised
|
|
139
|
+
*/
|
|
140
|
+
reportOutput(sessionName, runtime, text, source) {
|
|
141
|
+
if (!this.state.settings.enabled || source === 'screen' || this.isExempt(sessionName))
|
|
142
|
+
return false;
|
|
143
|
+
if ((this.mutedUntil.get(runtime) ?? 0) > this.now())
|
|
144
|
+
return false;
|
|
145
|
+
let match = detectUsageLimit(text, runtime, this.now(), this.deps.timeZone);
|
|
146
|
+
if (!match)
|
|
147
|
+
return false;
|
|
148
|
+
if (match.kind === 'transient') {
|
|
149
|
+
match = this.escalateTransient(sessionName, match);
|
|
150
|
+
if (!match)
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
void this.onUsageLimit(sessionName, runtime, match).catch((err) => this.logger.warn('Runtime fallback failed', { sessionName, runtime, error: err instanceof Error ? err.message : String(err) }));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Gate before a message is written into a session.
|
|
158
|
+
*
|
|
159
|
+
* @param sessionName - Session
|
|
160
|
+
* @param runtime - Runtime the caller resolved
|
|
161
|
+
* @returns `queue` while a switch runs or when the session's runtime is out of usage and a fallback exists
|
|
162
|
+
*/
|
|
163
|
+
beforeDelivery(sessionName, runtime) {
|
|
164
|
+
if (!this.state.settings.enabled || this.isExempt(sessionName))
|
|
165
|
+
return 'deliver';
|
|
166
|
+
if (this.switching.has(sessionName))
|
|
167
|
+
return 'queue';
|
|
168
|
+
const current = this.state.overrides[sessionName]?.runtime ?? runtime;
|
|
169
|
+
if (!this.state.exhausted[current])
|
|
170
|
+
return 'deliver';
|
|
171
|
+
if (sessionName === ORCHESTRATOR_SESSION_NAME && !this.state.settings.orcFollows)
|
|
172
|
+
return 'deliver';
|
|
173
|
+
if (!this.hasCandidateSync(current))
|
|
174
|
+
return 'deliver';
|
|
175
|
+
this.logger.info('Message for an agent whose runtime is out of usage — switching it first', { sessionName, runtime: current });
|
|
176
|
+
void this.switchSession(sessionName, { waitForSafePoint: true, flushAfter: true });
|
|
177
|
+
return 'queue';
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Runtime to launch a session on.
|
|
181
|
+
*
|
|
182
|
+
* @param input - Session, configured runtime
|
|
183
|
+
* @returns The decision
|
|
184
|
+
*/
|
|
185
|
+
async resolveLaunch(input) {
|
|
186
|
+
const configured = { runtime: input.configured, overridden: false };
|
|
187
|
+
if (!this.state.settings.enabled || this.isExempt(input.sessionName))
|
|
188
|
+
return configured;
|
|
189
|
+
const existing = this.state.overrides[input.sessionName];
|
|
190
|
+
if (existing) {
|
|
191
|
+
if (existing.primary !== input.configured) {
|
|
192
|
+
// The owner changed the member's runtime: the override is stale.
|
|
193
|
+
delete this.state.overrides[input.sessionName];
|
|
194
|
+
this.save();
|
|
195
|
+
}
|
|
196
|
+
else if (existing.revertPending && !this.state.exhausted[existing.primary]) {
|
|
197
|
+
// A stopped agent being started after its runtime came back.
|
|
198
|
+
this.finishRevertOffline(input.sessionName, existing);
|
|
199
|
+
return configured;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
return this.decision(existing.runtime);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!this.state.exhausted[input.configured])
|
|
206
|
+
return configured;
|
|
207
|
+
if (input.isOrchestrator && !this.state.settings.orcFollows)
|
|
208
|
+
return configured;
|
|
209
|
+
const agent = (await this.deps.getAgent(input.sessionName).catch(() => null)) ?? {
|
|
210
|
+
sessionName: input.sessionName,
|
|
211
|
+
name: input.sessionName,
|
|
212
|
+
primary: input.configured,
|
|
213
|
+
memberId: input.memberId,
|
|
214
|
+
teamId: input.teamId,
|
|
215
|
+
isOrchestrator: input.isOrchestrator,
|
|
216
|
+
};
|
|
217
|
+
const target = await this.pickFallback(agent, input.configured);
|
|
218
|
+
if (!target) {
|
|
219
|
+
this.markNoFallback(input.configured);
|
|
220
|
+
return configured;
|
|
221
|
+
}
|
|
222
|
+
await this.recordSwitch(agent, input.configured, target);
|
|
223
|
+
this.logger.info('Starting an agent on its fallback runtime (its runtime is out of usage)', {
|
|
224
|
+
sessionName: input.sessionName,
|
|
225
|
+
primary: input.configured,
|
|
226
|
+
runtime: target,
|
|
227
|
+
});
|
|
228
|
+
return this.decision(target);
|
|
229
|
+
}
|
|
230
|
+
// --------------------------------------------------------------- detection
|
|
231
|
+
/**
|
|
232
|
+
* Count a transient rate limit; escalate when they keep coming.
|
|
233
|
+
*
|
|
234
|
+
* @param sessionName - Session
|
|
235
|
+
* @param match - Transient match
|
|
236
|
+
* @returns A usage-limit match when escalated, else null
|
|
237
|
+
*/
|
|
238
|
+
escalateTransient(sessionName, match) {
|
|
239
|
+
const now = this.now();
|
|
240
|
+
const recent = (this.transients.get(sessionName) ?? []).filter((t) => now - t < C.TRANSIENT_WINDOW_MS);
|
|
241
|
+
recent.push(now);
|
|
242
|
+
this.transients.set(sessionName, recent);
|
|
243
|
+
if (recent.length < C.TRANSIENT_ESCALATE_COUNT) {
|
|
244
|
+
this.logger.debug('Transient rate limit — the runtime retries it', { sessionName, rule: match.ruleId, count: recent.length });
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
this.transients.delete(sessionName);
|
|
248
|
+
return { ...match, kind: 'usage_limit', ruleId: `${match.ruleId}+repeated`, resetAt: now + C.TRANSIENT_ESCALATED_HORIZON_MS };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A usage limit was seen in a session.
|
|
252
|
+
*
|
|
253
|
+
* @param sessionName - Session
|
|
254
|
+
* @param runtime - Runtime that is out of usage
|
|
255
|
+
* @param match - The match
|
|
256
|
+
*/
|
|
257
|
+
async onUsageLimit(sessionName, runtime, match) {
|
|
258
|
+
const known = this.state.exhausted[runtime];
|
|
259
|
+
if (known) {
|
|
260
|
+
if (match.resetAt && known.until !== new Date(match.resetAt).toISOString()) {
|
|
261
|
+
known.until = new Date(match.resetAt).toISOString();
|
|
262
|
+
this.save();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const confirmed = await this.confirm(runtime);
|
|
267
|
+
if (!confirmed)
|
|
268
|
+
return;
|
|
269
|
+
if (!this.state.exhausted[runtime]) {
|
|
270
|
+
this.state.exhausted[runtime] = {
|
|
271
|
+
runtime,
|
|
272
|
+
since: new Date(this.now()).toISOString(),
|
|
273
|
+
...(match.resetAt ? { until: new Date(match.resetAt).toISOString() } : {}),
|
|
274
|
+
ruleId: match.ruleId,
|
|
275
|
+
switched: [],
|
|
276
|
+
switchedTo: [],
|
|
277
|
+
notified: false,
|
|
278
|
+
};
|
|
279
|
+
this.save();
|
|
280
|
+
this.logger.info('Runtime is out of usage', { runtime, rule: match.ruleId, until: this.state.exhausted[runtime].until ?? null });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
await this.switchSession(sessionName, { waitForSafePoint: true, flushAfter: false });
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Confirm a detection with the runtime's probe (one probe per runtime at a time).
|
|
287
|
+
*
|
|
288
|
+
* @param runtime - Runtime
|
|
289
|
+
* @returns False when the probe shows the runtime works (a false positive)
|
|
290
|
+
*/
|
|
291
|
+
confirm(runtime) {
|
|
292
|
+
const running = this.confirming.get(runtime);
|
|
293
|
+
if (running)
|
|
294
|
+
return running;
|
|
295
|
+
const run = (async () => {
|
|
296
|
+
const result = await this.deps.probe(runtime).catch(() => 'unknown');
|
|
297
|
+
if (result === 'available') {
|
|
298
|
+
this.mutedUntil.set(runtime, this.now() + C.FALSE_POSITIVE_MUTE_MS);
|
|
299
|
+
this.logger.info('Usage-limit text seen but the runtime answers normally — ignoring it', { runtime });
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
return true;
|
|
303
|
+
})();
|
|
304
|
+
this.confirming.set(runtime, run);
|
|
305
|
+
void run.finally(() => this.confirming.delete(runtime));
|
|
306
|
+
return run;
|
|
307
|
+
}
|
|
308
|
+
// ------------------------------------------------------------------ switch
|
|
309
|
+
/**
|
|
310
|
+
* Move a session to the next runtime of its chain (or back to its primary).
|
|
311
|
+
*
|
|
312
|
+
* @param sessionName - Session
|
|
313
|
+
* @param opts - Wait for its safe point first; flush queued messages when nothing switched
|
|
314
|
+
* @returns What happened
|
|
315
|
+
*/
|
|
316
|
+
async switchSession(sessionName, opts) {
|
|
317
|
+
if (this.switching.has(sessionName))
|
|
318
|
+
return 'skipped';
|
|
319
|
+
this.switching.add(sessionName);
|
|
320
|
+
let outcome = 'skipped';
|
|
321
|
+
try {
|
|
322
|
+
outcome = await this.doSwitch(sessionName, opts.waitForSafePoint);
|
|
323
|
+
return outcome;
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
this.logger.warn('Runtime switch failed', { sessionName, error: err instanceof Error ? err.message : String(err) });
|
|
327
|
+
outcome = 'failed';
|
|
328
|
+
return outcome;
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
this.switching.delete(sessionName);
|
|
332
|
+
await this.afterSwitch(sessionName, outcome, opts.flushAfter);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* After a switch, once messages are no longer held back: re-deliver what
|
|
337
|
+
* waited for the agent and write out its queue. Messages the gate queued
|
|
338
|
+
* must not wait for an event that will not come, also when nothing switched.
|
|
339
|
+
*
|
|
340
|
+
* @param sessionName - Session
|
|
341
|
+
* @param outcome - How the switch went
|
|
342
|
+
* @param flushAlways - Flush the queue even when nothing switched
|
|
343
|
+
*/
|
|
344
|
+
async afterSwitch(sessionName, outcome, flushAlways) {
|
|
345
|
+
const moved = outcome === 'switched' || outcome === 'reverted';
|
|
346
|
+
if (moved)
|
|
347
|
+
await this.deps.redeliver(sessionName).catch(() => undefined);
|
|
348
|
+
if (moved || flushAlways)
|
|
349
|
+
await this.deps.flushQueued(sessionName).catch(() => undefined);
|
|
350
|
+
}
|
|
351
|
+
async doSwitch(sessionName, waitForSafePoint) {
|
|
352
|
+
const agent = await this.deps.getAgent(sessionName);
|
|
353
|
+
if (!agent)
|
|
354
|
+
return 'skipped';
|
|
355
|
+
if (agent.isOrchestrator && !this.state.settings.orcFollows)
|
|
356
|
+
return 'skipped';
|
|
357
|
+
const current = this.state.overrides[sessionName]?.runtime ?? agent.primary;
|
|
358
|
+
if (!this.state.exhausted[current])
|
|
359
|
+
return 'skipped';
|
|
360
|
+
if (waitForSafePoint)
|
|
361
|
+
await this.waitForSafePoint(sessionName);
|
|
362
|
+
const target = await this.pickFallback(agent, current);
|
|
363
|
+
if (!target) {
|
|
364
|
+
this.markNoFallback(current);
|
|
365
|
+
this.logger.warn('No fallback runtime available — the agent waits for its runtime to reset', { sessionName, runtime: current });
|
|
366
|
+
return 'no_fallback';
|
|
367
|
+
}
|
|
368
|
+
const handover = await this.deps
|
|
369
|
+
.writeHandover({
|
|
370
|
+
sessionName,
|
|
371
|
+
from: current,
|
|
372
|
+
to: target,
|
|
373
|
+
direction: 'switch',
|
|
374
|
+
conversationId: this.deps.conversation.get(sessionName) ?? null,
|
|
375
|
+
workItem: await this.deps.getActiveWorkItem(sessionName).catch(() => null),
|
|
376
|
+
})
|
|
377
|
+
.catch(() => null);
|
|
378
|
+
await this.recordSwitch(agent, current, target, handover);
|
|
379
|
+
this.logger.info('Switching agent to its fallback runtime', { sessionName, from: current, to: target, handover });
|
|
380
|
+
const ok = await this.deps.relaunch(agent).catch(() => false);
|
|
381
|
+
if (!ok) {
|
|
382
|
+
this.logger.warn('Relaunch on the fallback runtime failed (the override stays; the next start uses it)', { sessionName, runtime: target });
|
|
383
|
+
return 'failed';
|
|
384
|
+
}
|
|
385
|
+
return 'switched';
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Record an override (or clear it when the target is the primary) and
|
|
389
|
+
* prepare the kickoff note.
|
|
390
|
+
*
|
|
391
|
+
* @param agent - Agent
|
|
392
|
+
* @param from - Runtime it leaves (out of usage)
|
|
393
|
+
* @param target - Runtime it goes to
|
|
394
|
+
* @param handover - Handover file, when written
|
|
395
|
+
*/
|
|
396
|
+
async recordSwitch(agent, from, target, handover = null) {
|
|
397
|
+
const sessionName = agent.sessionName;
|
|
398
|
+
const existing = this.state.overrides[sessionName];
|
|
399
|
+
const exhausted = this.state.exhausted[from];
|
|
400
|
+
// The primary's conversation is kept for the switch back; the fallback
|
|
401
|
+
// runtime starts fresh.
|
|
402
|
+
const primarySessionId = existing?.primarySessionId ?? (from === agent.primary ? this.deps.conversation.get(sessionName) : undefined);
|
|
403
|
+
this.deps.conversation.clear(sessionName);
|
|
404
|
+
if (target === agent.primary) {
|
|
405
|
+
delete this.state.overrides[sessionName];
|
|
406
|
+
if (primarySessionId)
|
|
407
|
+
this.deps.conversation.set(sessionName, primarySessionId);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
this.state.overrides[sessionName] = {
|
|
411
|
+
runtime: target,
|
|
412
|
+
primary: agent.primary,
|
|
413
|
+
reason: 'usage_limit',
|
|
414
|
+
since: existing?.since ?? new Date(this.now()).toISOString(),
|
|
415
|
+
...(this.state.exhausted[agent.primary]?.until ? { until: this.state.exhausted[agent.primary].until } : {}),
|
|
416
|
+
...(primarySessionId ? { primarySessionId } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (exhausted) {
|
|
420
|
+
if (!exhausted.switched.includes(sessionName))
|
|
421
|
+
exhausted.switched.push(sessionName);
|
|
422
|
+
if (!exhausted.switchedTo.includes(target))
|
|
423
|
+
exhausted.switchedTo.push(target);
|
|
424
|
+
}
|
|
425
|
+
this.save();
|
|
426
|
+
const workItem = await this.deps.getActiveWorkItem(sessionName).catch(() => null);
|
|
427
|
+
this.kickoffNotes.set(sessionName, this.switchNote(from, target, exhausted?.until, handover, workItem));
|
|
428
|
+
this.scheduleNotice(from);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Wait until the agent is not mid-turn (bounded: a usage limit ends the turn anyway).
|
|
432
|
+
*
|
|
433
|
+
* @param sessionName - Session
|
|
434
|
+
*/
|
|
435
|
+
async waitForSafePoint(sessionName) {
|
|
436
|
+
const deadline = this.now() + C.SAFE_POINT_MAX_WAIT_MS;
|
|
437
|
+
while (this.now() < deadline) {
|
|
438
|
+
const busy = await this.deps.isBusy(sessionName).catch(() => false);
|
|
439
|
+
if (!busy)
|
|
440
|
+
return;
|
|
441
|
+
await this.sleep(C.SAFE_POINT_POLL_MS);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* First runtime of the agent's chain that is not `current`, not out of
|
|
446
|
+
* usage, and installed + signed in.
|
|
447
|
+
*
|
|
448
|
+
* @param agent - Agent
|
|
449
|
+
* @param current - Runtime it is on (out of usage)
|
|
450
|
+
* @returns Runtime, or null
|
|
451
|
+
*/
|
|
452
|
+
async pickFallback(agent, current) {
|
|
453
|
+
const available = await this.getAvailability();
|
|
454
|
+
const selectable = new Set(available.filter((a) => a.selectable).map((a) => a.runtime));
|
|
455
|
+
for (const runtime of this.chainFor(agent)) {
|
|
456
|
+
if (runtime === current || this.state.exhausted[runtime])
|
|
457
|
+
continue;
|
|
458
|
+
if (selectable.has(runtime))
|
|
459
|
+
return runtime;
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The agent's chain: its own, or the global one.
|
|
465
|
+
*
|
|
466
|
+
* @param agent - Agent
|
|
467
|
+
* @returns Chain
|
|
468
|
+
*/
|
|
469
|
+
chainFor(agent) {
|
|
470
|
+
const own = agent.memberId ? this.state.settings.memberChains[agent.memberId] : undefined;
|
|
471
|
+
return own && own.length > 0 ? own : this.state.settings.chain;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Whether some runtime could take over (synchronous; uses the cached
|
|
475
|
+
* availability, optimistic when nothing is cached yet).
|
|
476
|
+
*
|
|
477
|
+
* @param current - Runtime out of usage
|
|
478
|
+
* @returns True when a candidate exists
|
|
479
|
+
*/
|
|
480
|
+
hasCandidateSync(current) {
|
|
481
|
+
const candidates = this.state.settings.chain.filter((r) => r !== current && !this.state.exhausted[r]);
|
|
482
|
+
const memberCandidates = Object.values(this.state.settings.memberChains).flat().filter((r) => r !== current && !this.state.exhausted[r]);
|
|
483
|
+
const all = [...candidates, ...memberCandidates];
|
|
484
|
+
if (!this.availability)
|
|
485
|
+
return all.length > 0;
|
|
486
|
+
const selectable = new Set(this.availability.list.filter((a) => a.selectable).map((a) => a.runtime));
|
|
487
|
+
return all.some((r) => selectable.has(r));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Availability, cached for 5 minutes.
|
|
491
|
+
*
|
|
492
|
+
* @param force - Ignore the cache
|
|
493
|
+
* @returns List
|
|
494
|
+
*/
|
|
495
|
+
async getAvailability(force = false) {
|
|
496
|
+
const ttl = 5 * 60_000;
|
|
497
|
+
if (!force && this.availability && this.now() - this.availability.at < ttl)
|
|
498
|
+
return this.availability.list;
|
|
499
|
+
try {
|
|
500
|
+
const list = await this.deps.getAvailability(this.state.settings);
|
|
501
|
+
this.availability = { at: this.now(), list };
|
|
502
|
+
return list;
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
this.logger.warn('Could not read runtime availability', { error: err instanceof Error ? err.message : String(err) });
|
|
506
|
+
return this.availability?.list ?? [];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
markNoFallback(runtime) {
|
|
510
|
+
const exhausted = this.state.exhausted[runtime];
|
|
511
|
+
if (!exhausted || exhausted.noFallback)
|
|
512
|
+
return;
|
|
513
|
+
exhausted.noFallback = true;
|
|
514
|
+
this.save();
|
|
515
|
+
this.scheduleNotice(runtime);
|
|
516
|
+
}
|
|
517
|
+
// ------------------------------------------------------------- switch back
|
|
518
|
+
/**
|
|
519
|
+
* Periodic work: owner notices, switch-back checks, idle-boundary reverts.
|
|
520
|
+
*/
|
|
521
|
+
async tick() {
|
|
522
|
+
if (this.ticking)
|
|
523
|
+
return;
|
|
524
|
+
this.ticking = true;
|
|
525
|
+
try {
|
|
526
|
+
await this.flushNotices();
|
|
527
|
+
for (const runtime of Object.keys(this.state.exhausted)) {
|
|
528
|
+
await this.checkRecovery(runtime);
|
|
529
|
+
}
|
|
530
|
+
await this.revertIdle();
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
this.logger.warn('Runtime fallback tick failed', { error: err instanceof Error ? err.message : String(err) });
|
|
534
|
+
}
|
|
535
|
+
finally {
|
|
536
|
+
this.ticking = false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Is an exhausted runtime back?
|
|
541
|
+
*
|
|
542
|
+
* @param runtime - Runtime
|
|
543
|
+
*/
|
|
544
|
+
async checkRecovery(runtime) {
|
|
545
|
+
const entry = this.state.exhausted[runtime];
|
|
546
|
+
if (!entry)
|
|
547
|
+
return;
|
|
548
|
+
const now = this.now();
|
|
549
|
+
const until = entry.until ? Date.parse(entry.until) : null;
|
|
550
|
+
const lastCheck = Date.parse(entry.lastProbeAt ?? entry.since);
|
|
551
|
+
const intervalMs = this.state.settings.probeIntervalMinutes * 60_000;
|
|
552
|
+
const due = until !== null ? now >= until + C.RESET_GRACE_MS && now - lastCheck >= C.RESET_GRACE_MS : now - lastCheck >= intervalMs;
|
|
553
|
+
if (!due)
|
|
554
|
+
return;
|
|
555
|
+
const result = await this.deps.probe(runtime).catch(() => 'unknown');
|
|
556
|
+
entry.lastProbeAt = new Date(now).toISOString();
|
|
557
|
+
if (result === 'limited') {
|
|
558
|
+
// The reset time passed but it is still limited: probe on the interval from now on.
|
|
559
|
+
if (until !== null && now >= until)
|
|
560
|
+
delete entry.until;
|
|
561
|
+
this.save();
|
|
562
|
+
this.logger.info('Runtime is still out of usage', { runtime });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// `available`, or `unknown` once its reset time passed / the interval
|
|
566
|
+
// ran out (an optimistic retry: a new limit is detected again).
|
|
567
|
+
await this.recover(runtime);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* A runtime has usage again: mark every agent that left it for revert and tell the owner.
|
|
571
|
+
*
|
|
572
|
+
* @param runtime - Runtime
|
|
573
|
+
*/
|
|
574
|
+
async recover(runtime) {
|
|
575
|
+
const entry = this.state.exhausted[runtime];
|
|
576
|
+
if (!entry)
|
|
577
|
+
return;
|
|
578
|
+
delete this.state.exhausted[runtime];
|
|
579
|
+
this.noticeDue.delete(runtime);
|
|
580
|
+
const reverting = [];
|
|
581
|
+
for (const override of Object.values(this.state.overrides)) {
|
|
582
|
+
if (override.primary === runtime) {
|
|
583
|
+
override.revertPending = true;
|
|
584
|
+
reverting.push(override);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
this.save();
|
|
588
|
+
this.logger.info('Runtime has usage again', { runtime, agentsToRevert: reverting.length });
|
|
589
|
+
if (entry.notified && reverting.length > 0) {
|
|
590
|
+
const fallbacks = [...new Set(reverting.map((o) => this.label(o.runtime)))];
|
|
591
|
+
await this.notify(`${this.label(runtime)} is available again on ${this.deps.machineName()}. ` +
|
|
592
|
+
`${agents(reverting.length)} ${reverting.length === 1 ? 'is' : 'are'} switching back from ${fallbacks.join(' / ')} as ${reverting.length === 1 ? 'it finishes its' : 'they finish their'} current turn.`);
|
|
593
|
+
}
|
|
594
|
+
await this.revertIdle();
|
|
595
|
+
}
|
|
596
|
+
/** Revert every pending override whose agent is idle (or not running). */
|
|
597
|
+
async revertIdle() {
|
|
598
|
+
for (const [sessionName, override] of Object.entries(this.state.overrides)) {
|
|
599
|
+
if (this.state.exhausted[override.primary])
|
|
600
|
+
continue;
|
|
601
|
+
if (!override.revertPending) {
|
|
602
|
+
// State from before a restart whose primary is no longer marked out of usage.
|
|
603
|
+
override.revertPending = true;
|
|
604
|
+
this.save();
|
|
605
|
+
}
|
|
606
|
+
if (this.switching.has(sessionName))
|
|
607
|
+
continue;
|
|
608
|
+
if (!this.deps.isLive(sessionName)) {
|
|
609
|
+
this.finishRevertOffline(sessionName, override);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (await this.deps.isBusy(sessionName).catch(() => true))
|
|
613
|
+
continue;
|
|
614
|
+
await this.revertSession(sessionName);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Clear the override of an agent that is not running.
|
|
619
|
+
*
|
|
620
|
+
* @param sessionName - Session
|
|
621
|
+
* @param override - Its override
|
|
622
|
+
*/
|
|
623
|
+
finishRevertOffline(sessionName, override) {
|
|
624
|
+
delete this.state.overrides[sessionName];
|
|
625
|
+
if (override.primarySessionId)
|
|
626
|
+
this.deps.conversation.set(sessionName, override.primarySessionId);
|
|
627
|
+
else
|
|
628
|
+
this.deps.conversation.clear(sessionName);
|
|
629
|
+
this.save();
|
|
630
|
+
this.logger.info('Fallback ended for a stopped agent (its next start uses its own runtime)', { sessionName, runtime: override.primary });
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Move an idle agent back to its configured runtime.
|
|
634
|
+
*
|
|
635
|
+
* @param sessionName - Session
|
|
636
|
+
* @returns Outcome
|
|
637
|
+
*/
|
|
638
|
+
async revertSession(sessionName) {
|
|
639
|
+
if (!this.state.overrides[sessionName] || this.switching.has(sessionName))
|
|
640
|
+
return 'skipped';
|
|
641
|
+
this.switching.add(sessionName);
|
|
642
|
+
let outcome = 'skipped';
|
|
643
|
+
try {
|
|
644
|
+
outcome = await this.doRevert(sessionName);
|
|
645
|
+
return outcome;
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
this.switching.delete(sessionName);
|
|
649
|
+
await this.afterSwitch(sessionName, outcome, false);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async doRevert(sessionName) {
|
|
653
|
+
const override = this.state.overrides[sessionName];
|
|
654
|
+
if (!override)
|
|
655
|
+
return 'skipped';
|
|
656
|
+
{
|
|
657
|
+
// Last look right before: anything that started since means "not now".
|
|
658
|
+
if (await this.deps.isBusy(sessionName).catch(() => true))
|
|
659
|
+
return 'skipped';
|
|
660
|
+
const agent = (await this.deps.getAgent(sessionName)) ?? null;
|
|
661
|
+
if (!agent)
|
|
662
|
+
return 'skipped';
|
|
663
|
+
const workItem = await this.deps.getActiveWorkItem(sessionName).catch(() => null);
|
|
664
|
+
const handover = await this.deps
|
|
665
|
+
.writeHandover({
|
|
666
|
+
sessionName,
|
|
667
|
+
from: override.runtime,
|
|
668
|
+
to: override.primary,
|
|
669
|
+
direction: 'revert',
|
|
670
|
+
conversationId: this.deps.conversation.get(sessionName) ?? null,
|
|
671
|
+
workItem,
|
|
672
|
+
})
|
|
673
|
+
.catch(() => null);
|
|
674
|
+
delete this.state.overrides[sessionName];
|
|
675
|
+
if (override.primarySessionId)
|
|
676
|
+
this.deps.conversation.set(sessionName, override.primarySessionId);
|
|
677
|
+
else
|
|
678
|
+
this.deps.conversation.clear(sessionName);
|
|
679
|
+
this.save();
|
|
680
|
+
this.kickoffNotes.set(sessionName, this.revertNote(override, handover, workItem));
|
|
681
|
+
this.logger.info('Switching agent back to its own runtime at an idle boundary', { sessionName, from: override.runtime, to: override.primary });
|
|
682
|
+
const ok = await this.deps.relaunch(agent).catch(() => false);
|
|
683
|
+
if (!ok) {
|
|
684
|
+
this.logger.warn('Relaunch on the primary runtime failed (its next start uses it)', { sessionName });
|
|
685
|
+
return 'failed';
|
|
686
|
+
}
|
|
687
|
+
return 'reverted';
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// ----------------------------------------------------------------- notices
|
|
691
|
+
scheduleNotice(runtime) {
|
|
692
|
+
const entry = this.state.exhausted[runtime];
|
|
693
|
+
if (!entry || entry.notified || this.noticeDue.has(runtime))
|
|
694
|
+
return;
|
|
695
|
+
this.noticeDue.set(runtime, this.now() + C.NOTICE_DELAY_MS);
|
|
696
|
+
const t = setTimeout(() => void this.flushNotices(), C.NOTICE_DELAY_MS + 100);
|
|
697
|
+
t.unref?.();
|
|
698
|
+
}
|
|
699
|
+
/** Send every owner notice that is due (once per event). */
|
|
700
|
+
async flushNotices() {
|
|
701
|
+
// Events from before a restart that were never sent.
|
|
702
|
+
for (const entry of Object.values(this.state.exhausted)) {
|
|
703
|
+
if (!entry.notified && !this.noticeDue.has(entry.runtime) && (entry.switched.length > 0 || entry.noFallback)) {
|
|
704
|
+
this.noticeDue.set(entry.runtime, this.now());
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const [runtime, due] of [...this.noticeDue]) {
|
|
708
|
+
if (due > this.now())
|
|
709
|
+
continue;
|
|
710
|
+
const entry = this.state.exhausted[runtime];
|
|
711
|
+
if (!entry || entry.notified) {
|
|
712
|
+
this.noticeDue.delete(runtime);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
const text = await this.limitNoticeText(entry);
|
|
716
|
+
const sent = await this.notify(text);
|
|
717
|
+
if (sent) {
|
|
718
|
+
entry.notified = true;
|
|
719
|
+
this.noticeDue.delete(runtime);
|
|
720
|
+
this.save();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async limitNoticeText(entry) {
|
|
725
|
+
const label = this.label(entry.runtime);
|
|
726
|
+
const reset = entry.until ? ` (resets ~${this.formatTime(Date.parse(entry.until))})` : '';
|
|
727
|
+
const head = `${label} hit its usage limit on ${this.deps.machineName()}${reset}.`;
|
|
728
|
+
const n = entry.switched.length;
|
|
729
|
+
if (n === 0) {
|
|
730
|
+
return `${head} No fallback runtime is available, so its agents wait until it resets. Set one in Settings → Runtimes.`;
|
|
731
|
+
}
|
|
732
|
+
const targets = entry.switchedTo.map((r) => this.label(r)).join(' / ');
|
|
733
|
+
let text = `${head} ${agents(n)} switched to ${targets} until then.`;
|
|
734
|
+
const total = this.deps.countAgentsOnRuntime ? await this.deps.countAgentsOnRuntime(entry.runtime).catch(() => n) : n;
|
|
735
|
+
if (total > n)
|
|
736
|
+
text += ' The others switch when they next get work.';
|
|
737
|
+
if (entry.noFallback)
|
|
738
|
+
text += ' Some agents had no fallback available and wait for the reset.';
|
|
739
|
+
return text;
|
|
740
|
+
}
|
|
741
|
+
async notify(text) {
|
|
742
|
+
const notifier = this.deps.notifier?.() ?? null;
|
|
743
|
+
if (!notifier)
|
|
744
|
+
return false;
|
|
745
|
+
try {
|
|
746
|
+
const delivered = await notifier.sendToOwner(text);
|
|
747
|
+
return delivered !== false;
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// -------------------------------------------------------------------- text
|
|
754
|
+
formatTime(epochMs) {
|
|
755
|
+
const tz = this.deps.timeZone;
|
|
756
|
+
const sameDay = new Date(epochMs).toDateString() === new Date(this.now()).toDateString() && epochMs - this.now() < 24 * 3600_000;
|
|
757
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
758
|
+
...(sameDay ? {} : { month: 'short', day: 'numeric' }),
|
|
759
|
+
hour: 'numeric',
|
|
760
|
+
minute: '2-digit',
|
|
761
|
+
timeZoneName: 'short',
|
|
762
|
+
...(tz ? { timeZone: tz } : {}),
|
|
763
|
+
}).format(new Date(epochMs));
|
|
764
|
+
}
|
|
765
|
+
switchNote(from, to, until, handover, workItem) {
|
|
766
|
+
const reset = until ? ` (it resets ~${this.formatTime(Date.parse(until))})` : '';
|
|
767
|
+
const parts = [
|
|
768
|
+
`Crewly moved you from ${this.label(from)} to ${this.label(to)} because ${this.label(from)} hit its usage limit${reset}; you will be moved back when it resets.`,
|
|
769
|
+
handover
|
|
770
|
+
? `This is a fresh conversation: after registering, read ${handover} once — it holds the end of your previous conversation.`
|
|
771
|
+
: 'This is a fresh conversation: your tasks, teams and wiki are all still in Crewly.',
|
|
772
|
+
];
|
|
773
|
+
if (workItem)
|
|
774
|
+
parts.push(`Then continue WorkItem ${workItem.id} ("${workItem.title}") where you left off.`);
|
|
775
|
+
return parts.join(' ');
|
|
776
|
+
}
|
|
777
|
+
revertNote(override, handover, workItem) {
|
|
778
|
+
const parts = [
|
|
779
|
+
`${this.label(override.primary)} is available again, so Crewly moved you back from ${this.label(override.runtime)}.`,
|
|
780
|
+
handover
|
|
781
|
+
? `Work went on while you were away: read ${handover} once for what happened since.`
|
|
782
|
+
: 'Work went on while you were away: check your WorkItems and wiki for what happened since.',
|
|
783
|
+
];
|
|
784
|
+
if (workItem)
|
|
785
|
+
parts.push(`Then continue WorkItem ${workItem.id} ("${workItem.title}").`);
|
|
786
|
+
return parts.join(' ');
|
|
787
|
+
}
|
|
788
|
+
// ---------------------------------------------------------------- snapshot
|
|
789
|
+
/**
|
|
790
|
+
* Badge data for an agent running on a fallback.
|
|
791
|
+
*
|
|
792
|
+
* @param sessionName - Session
|
|
793
|
+
* @returns Override view, or null
|
|
794
|
+
*/
|
|
795
|
+
overrideView(sessionName) {
|
|
796
|
+
const override = this.state.overrides[sessionName];
|
|
797
|
+
if (!override)
|
|
798
|
+
return null;
|
|
799
|
+
const label = this.label(override.runtime);
|
|
800
|
+
return {
|
|
801
|
+
...override,
|
|
802
|
+
sessionName,
|
|
803
|
+
runtimeLabel: label,
|
|
804
|
+
primaryLabel: this.label(override.primary),
|
|
805
|
+
badge: `on ${label} (${runtimeShortLabel(override.primary)} limit)`,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Everything the API shows.
|
|
810
|
+
*
|
|
811
|
+
* @returns Snapshot
|
|
812
|
+
*/
|
|
813
|
+
async snapshot() {
|
|
814
|
+
const runtimes = (await this.getAvailability()).map((a) => ({ ...a, exhausted: Boolean(this.state.exhausted[a.runtime]) }));
|
|
815
|
+
return {
|
|
816
|
+
settings: this.getSettings(),
|
|
817
|
+
runtimes,
|
|
818
|
+
exhausted: Object.values(this.state.exhausted).map((e) => ({ ...e })),
|
|
819
|
+
overrides: Object.keys(this.state.overrides)
|
|
820
|
+
.map((s) => this.overrideView(s))
|
|
821
|
+
.filter((v) => v !== null),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
// ------------------------------------------------------------------ helpers
|
|
825
|
+
label(runtime) {
|
|
826
|
+
return runtimeLabel(runtime, this.state.settings.crewlyAgentModel);
|
|
827
|
+
}
|
|
828
|
+
decision(runtime) {
|
|
829
|
+
return {
|
|
830
|
+
runtime,
|
|
831
|
+
overridden: true,
|
|
832
|
+
...(runtime === RUNTIME_TYPES.CREWLY_AGENT ? { crewlyAgentModel: this.state.settings.crewlyAgentModel } : {}),
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
isExempt(sessionName) {
|
|
836
|
+
if (this.deps.isExempt)
|
|
837
|
+
return this.deps.isExempt(sessionName);
|
|
838
|
+
return sessionName.startsWith(C.SMOKE.TEAM_PREFIX);
|
|
839
|
+
}
|
|
840
|
+
save() {
|
|
841
|
+
try {
|
|
842
|
+
this.deps.store.save(this.state);
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
this.logger.warn('Could not save runtime-fallback state', { error: err instanceof Error ? err.message : String(err) });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/** The backend's instance (set by the wiring at boot). */
|
|
850
|
+
let backendInstance = null;
|
|
851
|
+
/**
|
|
852
|
+
* The running backend's runtime-fallback service.
|
|
853
|
+
*
|
|
854
|
+
* @returns The instance, or null before boot wired it (and in tests)
|
|
855
|
+
*/
|
|
856
|
+
export function getRuntimeFallbackService() {
|
|
857
|
+
return backendInstance;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Set (or clear) the backend instance.
|
|
861
|
+
*
|
|
862
|
+
* @param service - Instance, or null
|
|
863
|
+
*/
|
|
864
|
+
export function setRuntimeFallbackService(service) {
|
|
865
|
+
backendInstance = service;
|
|
866
|
+
}
|
|
867
|
+
//# sourceMappingURL=runtime-fallback.service.js.map
|