instar 0.6.15 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/setup-wizard/skill.md +78 -49
- package/dist/commands/server.js +17 -1
- package/dist/commands/setup.js +95 -21
- package/dist/core/types.d.ts +12 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/monitoring/QuotaExhaustionDetector.d.ts +21 -0
- package/dist/monitoring/QuotaExhaustionDetector.js +136 -0
- package/dist/monitoring/QuotaTracker.d.ts +41 -5
- package/dist/monitoring/QuotaTracker.js +103 -14
- package/dist/scheduler/JobScheduler.d.ts +9 -0
- package/dist/scheduler/JobScheduler.js +33 -1
- package/package.json +1 -1
|
@@ -24,43 +24,68 @@ This wizard runs in a terminal that may be narrow (80-120 chars). Long text gets
|
|
|
24
24
|
**Good** (fits in terminal):
|
|
25
25
|
> Everything here is just a starting point. You can change any of it later — or just tell your agent to adjust itself.
|
|
26
26
|
|
|
27
|
-
## Phase 1:
|
|
27
|
+
## Phase 1: Context Detection & Welcome
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
**Do NOT ask "how do you want to use Instar?"** Instead, detect the context automatically and present an intelligent default.
|
|
30
|
+
|
|
31
|
+
### Step 1a: Detect Environment
|
|
32
|
+
|
|
33
|
+
Run these checks BEFORE showing anything to the user:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Check if we're inside a git repository
|
|
37
|
+
git rev-parse --show-toplevel 2>/dev/null
|
|
38
|
+
|
|
39
|
+
# Get the repo name if it exists
|
|
40
|
+
basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null
|
|
41
|
+
|
|
42
|
+
# Check for common project indicators
|
|
43
|
+
ls package.json Cargo.toml pyproject.toml go.mod Gemfile pom.xml 2>/dev/null
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Step 1b: Present Context-Aware Welcome
|
|
47
|
+
|
|
48
|
+
**If inside a git repository:**
|
|
30
49
|
|
|
31
50
|
---
|
|
32
51
|
|
|
33
52
|
**Welcome to Instar!**
|
|
34
53
|
|
|
35
|
-
|
|
54
|
+
I see you're in **[repo-name]** — I'll set up a persistent agent for this project.
|
|
55
|
+
|
|
56
|
+
Your agent will monitor, build, and maintain this codebase. You'll talk to it through Telegram — no terminal needed after setup.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
Then proceed directly — no "project vs general" question needed. The context made it obvious.
|
|
61
|
+
|
|
62
|
+
If the user objects ("actually I want a personal agent, not a project agent"), accommodate immediately: "Got it — setting up a personal agent instead."
|
|
63
|
+
|
|
64
|
+
**If NOT inside a git repository:**
|
|
36
65
|
|
|
37
66
|
---
|
|
38
67
|
|
|
39
|
-
|
|
68
|
+
**Welcome to Instar!**
|
|
69
|
+
|
|
70
|
+
You're not inside a project, so I'll set up a personal agent — a persistent AI companion you talk to through Telegram.
|
|
71
|
+
|
|
72
|
+
It can research, schedule tasks, manage files, and grow over time.
|
|
73
|
+
|
|
74
|
+
---
|
|
40
75
|
|
|
41
|
-
|
|
42
|
-
2. **General Agent** — A personal agent on your computer. Like having a persistent AI assistant you talk to through Telegram.
|
|
76
|
+
Then ask: "What should your agent be called?" (default: "my-agent")
|
|
43
77
|
|
|
44
|
-
|
|
78
|
+
### Key principle: Telegram is the interface, always
|
|
45
79
|
|
|
46
|
-
|
|
47
|
-
- Use the current working directory as the project
|
|
48
|
-
- Default jobs focus on health checks, code monitoring, reflection
|
|
49
|
-
- The agent's identity centers on the project
|
|
80
|
+
Regardless of project or personal agent, **Telegram is how you talk to your agent**. This should be clear from the very first message. Don't present it as an optional add-on — it's the destination of this entire setup.
|
|
50
81
|
|
|
51
|
-
|
|
52
|
-
- Ask for a name for the agent (this becomes the directory name)
|
|
53
|
-
- Create the directory in the current location or home dir
|
|
54
|
-
- Default jobs focus on communication, scheduling, research
|
|
55
|
-
- **Telegram is essential** — without it, a general agent has no natural interface
|
|
56
|
-
- Frame the identity around being a personal assistant, not a code monitor
|
|
57
|
-
- The AGENT.md should emphasize: "I'm your personal agent. Talk to me through Telegram."
|
|
82
|
+
The terminal session is the on-ramp. Telegram is where the agent experience lives.
|
|
58
83
|
|
|
59
84
|
## Phase 2: Identity Bootstrap — The Birth Conversation
|
|
60
85
|
|
|
61
86
|
**This is the most important part.** Have a conversation to understand who the user is and who their agent will become. Keep it natural and concise.
|
|
62
87
|
|
|
63
|
-
For **
|
|
88
|
+
For **Personal Agents**: emphasize that this agent will be their persistent companion. It grows, learns, and communicates through Telegram. It's not a project tool — it's a presence.
|
|
64
89
|
|
|
65
90
|
For **Project Agents**: emphasize that this agent will own the project's health and development. It monitors, builds, and maintains.
|
|
66
91
|
|
|
@@ -225,30 +250,11 @@ This project uses instar for persistent agent capabilities.
|
|
|
225
250
|
- **Research before escalating** — Check tools first. Build solutions. "Needs human" is last resort.
|
|
226
251
|
```
|
|
227
252
|
|
|
228
|
-
## Phase 3:
|
|
229
|
-
|
|
230
|
-
Now that identity is established, move to the technical setup. This feels more natural — the user already knows what they're building and why.
|
|
231
|
-
|
|
232
|
-
### 3a. Project Detection
|
|
233
|
-
|
|
234
|
-
- The project directory is passed in the prompt (e.g., "The project to set up is at: /path/to/project")
|
|
235
|
-
- All files should be written there, not in the instar package directory
|
|
236
|
-
- Check if `.instar/config.json` already exists (offer to reconfigure or skip)
|
|
237
|
-
- Verify prerequisites: check that `tmux` and `claude` CLI are available
|
|
238
|
-
|
|
239
|
-
```bash
|
|
240
|
-
which tmux
|
|
241
|
-
which claude
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
### 3b. Server Configuration
|
|
253
|
+
## Phase 3: Telegram Setup — The Destination
|
|
245
254
|
|
|
246
|
-
|
|
247
|
-
- **Max sessions** (default: 3) — "This limits how many Claude sessions can run at once. 2-3 is usually right."
|
|
255
|
+
**Telegram comes BEFORE technical configuration.** It's the whole point — everything else supports getting the user onto Telegram.
|
|
248
256
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
**This terminal session is the on-ramp. Telegram is where the agent experience truly begins.** Frame it that way:
|
|
257
|
+
Frame it clearly:
|
|
252
258
|
|
|
253
259
|
> Right now we're in a terminal. Telegram is where your agent comes alive:
|
|
254
260
|
> - **Just talk** — no commands, no terminal, just conversation
|
|
@@ -256,9 +262,7 @@ which claude
|
|
|
256
262
|
> - **Mobile access** — your agent is always reachable
|
|
257
263
|
> - **Proactive** — your agent reaches out when something matters
|
|
258
264
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
For **General Agents**: Telegram is essential. Without it, there IS no natural interface. Be direct: "This is how you'll talk to your agent."
|
|
265
|
+
For **Personal Agents**: Telegram is essential. Without it, there IS no natural interface. Be direct: "This is how you'll talk to your agent."
|
|
262
266
|
|
|
263
267
|
For **Project Agents**: Telegram is strongly recommended. Frame it as: "Your agent can message you about builds, issues, and progress — you just reply."
|
|
264
268
|
|
|
@@ -382,7 +386,32 @@ curl -s "https://api.telegram.org/bot${TOKEN}/getUpdates?timeout=5"
|
|
|
382
386
|
- Look for `chat.id` where `chat.type` is "supergroup" or "group"
|
|
383
387
|
- If auto-detection fails, guide manual entry
|
|
384
388
|
|
|
385
|
-
|
|
389
|
+
## Phase 4: Technical Configuration
|
|
390
|
+
|
|
391
|
+
Now that identity and Telegram are established, handle the remaining technical setup. These should feel like sensible defaults, not interrogation.
|
|
392
|
+
|
|
393
|
+
### 4a. Project Detection
|
|
394
|
+
|
|
395
|
+
- The project directory is passed in the prompt (e.g., "The project to set up is at: /path/to/project")
|
|
396
|
+
- All files should be written there, not in the instar package directory
|
|
397
|
+
- Check if `.instar/config.json` already exists (offer to reconfigure or skip)
|
|
398
|
+
- Verify prerequisites: check that `tmux` and `claude` CLI are available
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
which tmux
|
|
402
|
+
which claude
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### 4b. Server Configuration
|
|
406
|
+
|
|
407
|
+
Present sensible defaults — don't make the user think about these unless they want to:
|
|
408
|
+
|
|
409
|
+
- **Port** (default: 4040) — "The agent runs a small local server."
|
|
410
|
+
- **Max sessions** (default: 3) — "How many Claude sessions can run at once."
|
|
411
|
+
|
|
412
|
+
Ask as a single confirmation: "I'll use port 4040 with up to 3 sessions. Want to change these?" If yes, ask for specifics. If no, move on.
|
|
413
|
+
|
|
414
|
+
### 4c. Job Scheduler (Optional)
|
|
386
415
|
|
|
387
416
|
- Ask if they want scheduled jobs
|
|
388
417
|
- If yes, walk through adding a first job:
|
|
@@ -393,7 +422,7 @@ curl -s "https://api.telegram.org/bot${TOKEN}/getUpdates?timeout=5"
|
|
|
393
422
|
- **Execution type**: prompt (AI instruction), script (shell script), or skill (slash command)
|
|
394
423
|
- Offer to add more jobs
|
|
395
424
|
|
|
396
|
-
###
|
|
425
|
+
### 4d. Write Configuration Files
|
|
397
426
|
|
|
398
427
|
Create the directory structure and write config files:
|
|
399
428
|
|
|
@@ -438,7 +467,7 @@ mkdir -p .instar/state/sessions .instar/state/jobs .instar/logs
|
|
|
438
467
|
|
|
439
468
|
**`.instar/users.json`**: Array of user objects from the identity conversation.
|
|
440
469
|
|
|
441
|
-
###
|
|
470
|
+
### 4e. Update .gitignore
|
|
442
471
|
|
|
443
472
|
Append if not present:
|
|
444
473
|
```
|
|
@@ -447,7 +476,7 @@ Append if not present:
|
|
|
447
476
|
.instar/logs/
|
|
448
477
|
```
|
|
449
478
|
|
|
450
|
-
## Phase
|
|
479
|
+
## Phase 5: Summary & Launch
|
|
451
480
|
|
|
452
481
|
Show what was created briefly, then get the user to their agent.
|
|
453
482
|
|
|
@@ -485,4 +514,4 @@ Offer to start the server.
|
|
|
485
514
|
|
|
486
515
|
## Starting
|
|
487
516
|
|
|
488
|
-
Begin by
|
|
517
|
+
Begin by detecting the environment (git repo check, project file check), then present the context-aware welcome. Let the conversation flow naturally from there.
|
package/dist/commands/server.js
CHANGED
|
@@ -28,6 +28,7 @@ import { TelegraphService } from '../publishing/TelegraphService.js';
|
|
|
28
28
|
import { PrivateViewer } from '../publishing/PrivateViewer.js';
|
|
29
29
|
import { TunnelManager } from '../tunnel/TunnelManager.js';
|
|
30
30
|
import { EvolutionManager } from '../core/EvolutionManager.js';
|
|
31
|
+
import { QuotaTracker } from '../monitoring/QuotaTracker.js';
|
|
31
32
|
/**
|
|
32
33
|
* Respawn a session for a topic, including thread history in the bootstrap.
|
|
33
34
|
* This prevents "thread drift" where respawned sessions lose context.
|
|
@@ -381,9 +382,24 @@ export async function startServer(options) {
|
|
|
381
382
|
const count = relationships.getAll().length;
|
|
382
383
|
console.log(pc.green(` Relationships loaded: ${count} tracked (${intelligenceMode})`));
|
|
383
384
|
}
|
|
385
|
+
// Set up quota tracking if enabled
|
|
386
|
+
let quotaTracker;
|
|
387
|
+
if (config.monitoring?.quotaTracking) {
|
|
388
|
+
const quotaFile = config.monitoring.quotaStateFile
|
|
389
|
+
|| path.join(config.stateDir, 'quota-state.json');
|
|
390
|
+
quotaTracker = new QuotaTracker({
|
|
391
|
+
quotaFile,
|
|
392
|
+
thresholds: config.scheduler?.quotaThresholds ?? { normal: 50, elevated: 60, critical: 80, shutdown: 95 },
|
|
393
|
+
});
|
|
394
|
+
console.log(pc.green(` Quota tracking enabled (${quotaFile})`));
|
|
395
|
+
}
|
|
384
396
|
let scheduler;
|
|
385
397
|
if (config.scheduler.enabled) {
|
|
386
398
|
scheduler = new JobScheduler(config.scheduler, sessionManager, state, config.stateDir);
|
|
399
|
+
if (quotaTracker) {
|
|
400
|
+
scheduler.canRunJob = quotaTracker.canRunJob.bind(quotaTracker);
|
|
401
|
+
scheduler.setQuotaTracker(quotaTracker);
|
|
402
|
+
}
|
|
387
403
|
scheduler.start();
|
|
388
404
|
console.log(pc.green(' Scheduler started'));
|
|
389
405
|
}
|
|
@@ -487,7 +503,7 @@ export async function startServer(options) {
|
|
|
487
503
|
...(config.evolution || {}),
|
|
488
504
|
});
|
|
489
505
|
console.log(pc.green(' Evolution system enabled'));
|
|
490
|
-
const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, publisher, viewer, tunnel, evolution });
|
|
506
|
+
const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, quotaTracker, publisher, viewer, tunnel, evolution });
|
|
491
507
|
await server.start();
|
|
492
508
|
// Start tunnel AFTER server is listening
|
|
493
509
|
if (tunnel) {
|
package/dist/commands/setup.js
CHANGED
|
@@ -69,8 +69,23 @@ export async function runSetup(opts) {
|
|
|
69
69
|
console.log(pc.dim(' Security is enforced through behavioral hooks, identity grounding, and'));
|
|
70
70
|
console.log(pc.dim(' scoped access — not permission dialogs. See: README.md > Security Model'));
|
|
71
71
|
console.log();
|
|
72
|
+
// Detect git context to pass to the conversational wizard
|
|
73
|
+
const projectDir = process.cwd();
|
|
74
|
+
let gitContext = '';
|
|
75
|
+
try {
|
|
76
|
+
const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
77
|
+
cwd: projectDir,
|
|
78
|
+
encoding: 'utf-8',
|
|
79
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
80
|
+
}).trim();
|
|
81
|
+
const repoName = path.basename(gitRoot);
|
|
82
|
+
gitContext = ` This directory is inside a git repository "${repoName}" at ${gitRoot}.`;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
gitContext = ' This directory is NOT inside a git repository.';
|
|
86
|
+
}
|
|
72
87
|
// Launch Claude Code from the instar package root (where .claude/skills/ lives)
|
|
73
|
-
// and pass the target project directory in the prompt.
|
|
88
|
+
// and pass the target project directory + git context in the prompt.
|
|
74
89
|
//
|
|
75
90
|
// --dangerously-skip-permissions is required here because the setup wizard
|
|
76
91
|
// runs in instar's OWN package directory (instarRoot), not the user's
|
|
@@ -78,10 +93,9 @@ export async function runSetup(opts) {
|
|
|
78
93
|
// user's project directory, which breaks the interactive flow. The wizard
|
|
79
94
|
// only writes to well-defined locations (.instar/, .claude/, CLAUDE.md).
|
|
80
95
|
const instarRoot = findInstarRoot();
|
|
81
|
-
const projectDir = process.cwd();
|
|
82
96
|
const child = spawn(claudePath, [
|
|
83
97
|
'--dangerously-skip-permissions',
|
|
84
|
-
`/setup-wizard The project to set up is at: ${projectDir}`,
|
|
98
|
+
`/setup-wizard The project to set up is at: ${projectDir}.${gitContext}`,
|
|
85
99
|
], {
|
|
86
100
|
cwd: instarRoot,
|
|
87
101
|
stdio: 'inherit',
|
|
@@ -126,6 +140,22 @@ function findInstarRoot() {
|
|
|
126
140
|
// Fallback: assume we're in dist/commands/ — go up to root
|
|
127
141
|
return path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..');
|
|
128
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Detect whether the current directory is inside a git repository.
|
|
145
|
+
*/
|
|
146
|
+
function detectGitRepo(dir) {
|
|
147
|
+
try {
|
|
148
|
+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
149
|
+
cwd: dir,
|
|
150
|
+
encoding: 'utf-8',
|
|
151
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
152
|
+
}).trim();
|
|
153
|
+
return { isRepo: true, repoRoot: root, repoName: path.basename(root) };
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { isRepo: false };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
129
159
|
/**
|
|
130
160
|
* Classic inquirer-based setup wizard.
|
|
131
161
|
* The original interactive setup experience.
|
|
@@ -133,7 +163,7 @@ function findInstarRoot() {
|
|
|
133
163
|
async function runClassicSetup() {
|
|
134
164
|
console.log();
|
|
135
165
|
console.log(pc.bold(' Welcome to Instar'));
|
|
136
|
-
console.log(pc.dim('
|
|
166
|
+
console.log(pc.dim(' Turn Claude Code into a persistent agent you talk to through Telegram.'));
|
|
137
167
|
console.log();
|
|
138
168
|
// ── Step 0: Check and install prerequisites ─────────────────────
|
|
139
169
|
const prereqs = await ensurePrerequisites();
|
|
@@ -143,19 +173,59 @@ async function runClassicSetup() {
|
|
|
143
173
|
const tmuxPath = prereqs.results.find(r => r.name === 'tmux').path;
|
|
144
174
|
// Use a scoped name to avoid shadowing the outer runSetup's claudePath
|
|
145
175
|
const claudePath = prereqs.results.find(r => r.name === 'Claude CLI').path;
|
|
146
|
-
// ── Step 1:
|
|
176
|
+
// ── Step 1: Detect context and determine mode ─────────────────
|
|
147
177
|
const detectedDir = process.cwd();
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
178
|
+
const gitInfo = detectGitRepo(detectedDir);
|
|
179
|
+
let projectDir;
|
|
180
|
+
let projectName;
|
|
181
|
+
let isProjectAgent;
|
|
182
|
+
if (gitInfo.isRepo) {
|
|
183
|
+
// Inside a git repository — suggest project agent
|
|
184
|
+
console.log(` ${pc.green('✓')} Detected git repository: ${pc.cyan(gitInfo.repoName)}`);
|
|
185
|
+
console.log(pc.dim(` ${gitInfo.repoRoot}`));
|
|
186
|
+
console.log();
|
|
187
|
+
console.log(pc.dim(' Your agent will live alongside this project — monitoring, building,'));
|
|
188
|
+
console.log(pc.dim(' and maintaining it. You talk to it through Telegram.'));
|
|
189
|
+
console.log();
|
|
190
|
+
const useThisRepo = await confirm({
|
|
191
|
+
message: `Set up an agent for ${gitInfo.repoName}?`,
|
|
192
|
+
default: true,
|
|
193
|
+
});
|
|
194
|
+
if (useThisRepo) {
|
|
195
|
+
projectDir = gitInfo.repoRoot;
|
|
196
|
+
projectName = await input({
|
|
197
|
+
message: 'Agent name',
|
|
198
|
+
default: gitInfo.repoName,
|
|
199
|
+
});
|
|
200
|
+
isProjectAgent = true;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// They want a general agent instead
|
|
204
|
+
projectName = await input({
|
|
205
|
+
message: 'What should your agent be called?',
|
|
206
|
+
default: 'my-agent',
|
|
207
|
+
});
|
|
208
|
+
projectDir = detectedDir;
|
|
209
|
+
isProjectAgent = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// Not in a git repo — this is a general/personal agent
|
|
214
|
+
console.log(pc.dim(' No git repository detected — setting up a personal agent.'));
|
|
215
|
+
console.log(pc.dim(' A personal agent lives on your machine and you talk to it through Telegram.'));
|
|
216
|
+
console.log();
|
|
217
|
+
projectName = await input({
|
|
218
|
+
message: 'What should your agent be called?',
|
|
219
|
+
default: 'my-agent',
|
|
220
|
+
});
|
|
221
|
+
projectDir = detectedDir;
|
|
222
|
+
isProjectAgent = false;
|
|
223
|
+
}
|
|
154
224
|
// Check if already initialized
|
|
155
225
|
const stateDir = path.join(projectDir, '.instar');
|
|
156
226
|
if (fs.existsSync(path.join(stateDir, 'config.json'))) {
|
|
157
227
|
const overwrite = await confirm({
|
|
158
|
-
message: 'Agent
|
|
228
|
+
message: 'Agent already initialized here. Reconfigure?',
|
|
159
229
|
default: false,
|
|
160
230
|
});
|
|
161
231
|
if (!overwrite) {
|
|
@@ -163,7 +233,19 @@ async function runClassicSetup() {
|
|
|
163
233
|
return;
|
|
164
234
|
}
|
|
165
235
|
}
|
|
166
|
-
// ── Step 2:
|
|
236
|
+
// ── Step 2: Telegram — the primary interface ───────────────────
|
|
237
|
+
console.log();
|
|
238
|
+
console.log(pc.bold(' Telegram — How You Talk to Your Agent'));
|
|
239
|
+
console.log();
|
|
240
|
+
console.log(pc.dim(' Once connected, you just talk — no commands, no terminal.'));
|
|
241
|
+
console.log(pc.dim(' Topic threads, message history, mobile access, proactive notifications.'));
|
|
242
|
+
if (!isProjectAgent) {
|
|
243
|
+
console.log();
|
|
244
|
+
console.log(pc.dim(' For a personal agent, Telegram IS the interface.'));
|
|
245
|
+
}
|
|
246
|
+
console.log();
|
|
247
|
+
const telegramConfig = await promptForTelegram();
|
|
248
|
+
// ── Step 3: Server config (sensible defaults) ──────────────────
|
|
167
249
|
const port = await number({
|
|
168
250
|
message: 'Server port',
|
|
169
251
|
default: 4040,
|
|
@@ -182,14 +264,6 @@ async function runClassicSetup() {
|
|
|
182
264
|
return true;
|
|
183
265
|
},
|
|
184
266
|
}) ?? 3;
|
|
185
|
-
// ── Step 3: Telegram (BEFORE users, so we know context) ────────
|
|
186
|
-
console.log();
|
|
187
|
-
console.log(pc.bold(' Telegram — Where Your Agent Lives'));
|
|
188
|
-
console.log(pc.dim(' Telegram is where the real experience begins.'));
|
|
189
|
-
console.log(pc.dim(' Once connected, you just talk — no commands, no terminal.'));
|
|
190
|
-
console.log(pc.dim(' Topic threads, message history, mobile access, proactive notifications.'));
|
|
191
|
-
console.log();
|
|
192
|
-
const telegramConfig = await promptForTelegram();
|
|
193
267
|
// ── Step 4: User setup ─────────────────────────────────────────
|
|
194
268
|
console.log();
|
|
195
269
|
const addUser = await confirm({
|
package/dist/core/types.d.ts
CHANGED
|
@@ -197,8 +197,10 @@ export interface MessagingAdapter {
|
|
|
197
197
|
resolveUser(channelIdentifier: string): Promise<string | null>;
|
|
198
198
|
}
|
|
199
199
|
export interface QuotaState {
|
|
200
|
-
/** Current usage percentage (0-100) */
|
|
200
|
+
/** Current weekly usage percentage (0-100) */
|
|
201
201
|
usagePercent: number;
|
|
202
|
+
/** 5-hour rolling rate limit utilization (0-100), if available */
|
|
203
|
+
fiveHourPercent?: number;
|
|
202
204
|
/** When usage data was last updated */
|
|
203
205
|
lastUpdated: string;
|
|
204
206
|
/** Per-account breakdown if multi-account */
|
|
@@ -209,9 +211,18 @@ export interface QuotaState {
|
|
|
209
211
|
export interface AccountQuota {
|
|
210
212
|
email: string;
|
|
211
213
|
usagePercent: number;
|
|
214
|
+
/** 5-hour rolling rate limit utilization for this account */
|
|
215
|
+
fiveHourPercent?: number;
|
|
212
216
|
isActive: boolean;
|
|
213
217
|
lastUpdated: string;
|
|
214
218
|
}
|
|
219
|
+
/** Cause of a session's death, as classified by QuotaExhaustionDetector */
|
|
220
|
+
export type SessionDeathCause = 'quota_exhaustion' | 'context_exhausted' | 'crash' | 'timeout' | 'normal_exit' | 'unknown';
|
|
221
|
+
export interface SessionDeathClassification {
|
|
222
|
+
cause: SessionDeathCause;
|
|
223
|
+
confidence: 'high' | 'medium' | 'low';
|
|
224
|
+
detail: string;
|
|
225
|
+
}
|
|
215
226
|
export interface HealthStatus {
|
|
216
227
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
217
228
|
components: Record<string, ComponentHealth>;
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export type { RouteContext } from './server/routes.js';
|
|
|
26
26
|
export { corsMiddleware, authMiddleware, rateLimiter, requestTimeout, errorHandler } from './server/middleware.js';
|
|
27
27
|
export { HealthChecker } from './monitoring/HealthChecker.js';
|
|
28
28
|
export { QuotaTracker } from './monitoring/QuotaTracker.js';
|
|
29
|
+
export type { RemoteQuotaResult } from './monitoring/QuotaTracker.js';
|
|
30
|
+
export { classifySessionDeath } from './monitoring/QuotaExhaustionDetector.js';
|
|
29
31
|
export { SleepWakeDetector } from './core/SleepWakeDetector.js';
|
|
30
32
|
export { TelegramAdapter } from './messaging/TelegramAdapter.js';
|
|
31
33
|
export type { TelegramConfig } from './messaging/TelegramAdapter.js';
|
|
@@ -35,6 +37,6 @@ export { PrivateViewer } from './publishing/PrivateViewer.js';
|
|
|
35
37
|
export type { PrivateView, PrivateViewerConfig } from './publishing/PrivateViewer.js';
|
|
36
38
|
export { TunnelManager } from './tunnel/TunnelManager.js';
|
|
37
39
|
export type { TunnelConfig, TunnelState } from './tunnel/TunnelManager.js';
|
|
38
|
-
export type { Session, SessionStatus, SessionManagerConfig, ModelTier, JobDefinition, JobPriority, JobExecution, JobState, JobSchedulerConfig, UserProfile, UserChannel, UserPreferences, Message, OutgoingMessage, MessagingAdapter, MessagingAdapterConfig, QuotaState, AccountQuota, HealthStatus, ComponentHealth, ActivityEvent, InstarConfig, MonitoringConfig, RelationshipRecord, RelationshipManagerConfig, InteractionSummary, FeedbackItem, FeedbackConfig, UpdateInfo, UpdateResult, DispatchConfig, UpdateConfig, PublishingConfig, TunnelConfigType, SkipReason, SkipEvent, WorkloadSignal, AutoTuneState, IntelligenceProvider, IntelligenceOptions, EvolutionProposal, EvolutionType, EvolutionStatus, LearningEntry, LearningSource, CapabilityGap, GapCategory, ActionItem, EvolutionManagerConfig, } from './core/types.js';
|
|
40
|
+
export type { Session, SessionStatus, SessionManagerConfig, ModelTier, JobDefinition, JobPriority, JobExecution, JobState, JobSchedulerConfig, UserProfile, UserChannel, UserPreferences, Message, OutgoingMessage, MessagingAdapter, MessagingAdapterConfig, QuotaState, AccountQuota, SessionDeathCause, SessionDeathClassification, HealthStatus, ComponentHealth, ActivityEvent, InstarConfig, MonitoringConfig, RelationshipRecord, RelationshipManagerConfig, InteractionSummary, FeedbackItem, FeedbackConfig, UpdateInfo, UpdateResult, DispatchConfig, UpdateConfig, PublishingConfig, TunnelConfigType, SkipReason, SkipEvent, WorkloadSignal, AutoTuneState, IntelligenceProvider, IntelligenceOptions, EvolutionProposal, EvolutionType, EvolutionStatus, LearningEntry, LearningSource, CapabilityGap, GapCategory, ActionItem, EvolutionManagerConfig, } from './core/types.js';
|
|
39
41
|
export type { Dispatch, DispatchCheckResult, DispatchEvaluation, EvaluationDecision, DispatchFeedback, DispatchStats } from './core/DispatchManager.js';
|
|
40
42
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ export { corsMiddleware, authMiddleware, rateLimiter, requestTimeout, errorHandl
|
|
|
28
28
|
// Monitoring
|
|
29
29
|
export { HealthChecker } from './monitoring/HealthChecker.js';
|
|
30
30
|
export { QuotaTracker } from './monitoring/QuotaTracker.js';
|
|
31
|
+
export { classifySessionDeath } from './monitoring/QuotaExhaustionDetector.js';
|
|
31
32
|
export { SleepWakeDetector } from './core/SleepWakeDetector.js';
|
|
32
33
|
// Messaging
|
|
33
34
|
export { TelegramAdapter } from './messaging/TelegramAdapter.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Exhaustion Detector — classifies WHY a session died.
|
|
3
|
+
*
|
|
4
|
+
* Pattern-matches tmux output from a dead session against known
|
|
5
|
+
* failure signatures, cross-references with quota state to produce
|
|
6
|
+
* a confidence-weighted classification.
|
|
7
|
+
*
|
|
8
|
+
* Use cases:
|
|
9
|
+
* - Skip ledger: record quota_exhaustion vs crash vs normal_exit
|
|
10
|
+
* - Telegram alerts: "session died because quota exhausted"
|
|
11
|
+
* - Auto-recovery: trigger account switch on repeated quota deaths
|
|
12
|
+
*
|
|
13
|
+
* Ported from Dawn's dawn-server/src/quota/QuotaExhaustionDetector.ts,
|
|
14
|
+
* simplified for general Instar use (no Telegram dependency).
|
|
15
|
+
*/
|
|
16
|
+
import type { QuotaState, SessionDeathClassification } from '../core/types.js';
|
|
17
|
+
/**
|
|
18
|
+
* Classify why a session died based on its terminal output and quota state.
|
|
19
|
+
*/
|
|
20
|
+
export declare function classifySessionDeath(tmuxOutput: string, quotaState?: QuotaState | null): SessionDeathClassification;
|
|
21
|
+
//# sourceMappingURL=QuotaExhaustionDetector.d.ts.map
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Exhaustion Detector — classifies WHY a session died.
|
|
3
|
+
*
|
|
4
|
+
* Pattern-matches tmux output from a dead session against known
|
|
5
|
+
* failure signatures, cross-references with quota state to produce
|
|
6
|
+
* a confidence-weighted classification.
|
|
7
|
+
*
|
|
8
|
+
* Use cases:
|
|
9
|
+
* - Skip ledger: record quota_exhaustion vs crash vs normal_exit
|
|
10
|
+
* - Telegram alerts: "session died because quota exhausted"
|
|
11
|
+
* - Auto-recovery: trigger account switch on repeated quota deaths
|
|
12
|
+
*
|
|
13
|
+
* Ported from Dawn's dawn-server/src/quota/QuotaExhaustionDetector.ts,
|
|
14
|
+
* simplified for general Instar use (no Telegram dependency).
|
|
15
|
+
*/
|
|
16
|
+
// Pattern groups for classification
|
|
17
|
+
const QUOTA_PATTERNS = [
|
|
18
|
+
'overloaded_error',
|
|
19
|
+
'rate_limit',
|
|
20
|
+
'429',
|
|
21
|
+
'quota.*exceeded',
|
|
22
|
+
'usage.*limit',
|
|
23
|
+
'too many requests',
|
|
24
|
+
'resource_exhausted',
|
|
25
|
+
'capacity',
|
|
26
|
+
'throttl',
|
|
27
|
+
'rate limit exceeded',
|
|
28
|
+
];
|
|
29
|
+
const CONTEXT_PATTERNS = [
|
|
30
|
+
'context.*exhaust',
|
|
31
|
+
'context.*limit',
|
|
32
|
+
'context.*full',
|
|
33
|
+
'token.*limit.*reached',
|
|
34
|
+
'maximum.*context',
|
|
35
|
+
'conversation is too long',
|
|
36
|
+
];
|
|
37
|
+
const CRASH_PATTERNS = [
|
|
38
|
+
'SIGABRT',
|
|
39
|
+
'SIGSEGV',
|
|
40
|
+
'SIGKILL',
|
|
41
|
+
'fatal error',
|
|
42
|
+
'unhandled.*exception',
|
|
43
|
+
'panic:',
|
|
44
|
+
'stack overflow',
|
|
45
|
+
'out of memory',
|
|
46
|
+
'heap.*out',
|
|
47
|
+
];
|
|
48
|
+
const NORMAL_EXIT_PATTERNS = [
|
|
49
|
+
'Session ended',
|
|
50
|
+
'Goodbye!',
|
|
51
|
+
'has been completed',
|
|
52
|
+
'Auto-exit',
|
|
53
|
+
'✓',
|
|
54
|
+
'exited cleanly',
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Classify why a session died based on its terminal output and quota state.
|
|
58
|
+
*/
|
|
59
|
+
export function classifySessionDeath(tmuxOutput, quotaState) {
|
|
60
|
+
if (!tmuxOutput || tmuxOutput.trim().length === 0) {
|
|
61
|
+
return { cause: 'unknown', confidence: 'low', detail: 'No output captured' };
|
|
62
|
+
}
|
|
63
|
+
const output = tmuxOutput.toLowerCase();
|
|
64
|
+
// Check each pattern group
|
|
65
|
+
const quotaMatch = matchPatterns(output, QUOTA_PATTERNS);
|
|
66
|
+
const contextMatch = matchPatterns(output, CONTEXT_PATTERNS);
|
|
67
|
+
const crashMatch = matchPatterns(output, CRASH_PATTERNS);
|
|
68
|
+
const normalMatch = matchPatterns(output, NORMAL_EXIT_PATTERNS);
|
|
69
|
+
// Normal exit takes precedence if present
|
|
70
|
+
if (normalMatch && !quotaMatch && !crashMatch) {
|
|
71
|
+
return {
|
|
72
|
+
cause: 'normal_exit',
|
|
73
|
+
confidence: 'high',
|
|
74
|
+
detail: `Matched normal exit pattern: "${normalMatch}"`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Quota exhaustion — cross-reference with state for confidence
|
|
78
|
+
if (quotaMatch) {
|
|
79
|
+
let confidence = 'medium';
|
|
80
|
+
// Cross-reference: if 5-hour is high, boost confidence
|
|
81
|
+
if (quotaState) {
|
|
82
|
+
const fiveHour = quotaState.fiveHourPercent;
|
|
83
|
+
const weekly = quotaState.usagePercent;
|
|
84
|
+
if (typeof fiveHour === 'number' && fiveHour > 90) {
|
|
85
|
+
confidence = 'high';
|
|
86
|
+
}
|
|
87
|
+
else if (weekly > 85) {
|
|
88
|
+
confidence = 'high';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
cause: 'quota_exhaustion',
|
|
93
|
+
confidence,
|
|
94
|
+
detail: `Matched quota pattern: "${quotaMatch}"` +
|
|
95
|
+
(quotaState ? ` (weekly: ${quotaState.usagePercent}%, 5h: ${quotaState.fiveHourPercent ?? 'n/a'}%)` : ''),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// Context exhaustion
|
|
99
|
+
if (contextMatch) {
|
|
100
|
+
return {
|
|
101
|
+
cause: 'context_exhausted',
|
|
102
|
+
confidence: 'medium',
|
|
103
|
+
detail: `Matched context pattern: "${contextMatch}"`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Crash
|
|
107
|
+
if (crashMatch) {
|
|
108
|
+
return {
|
|
109
|
+
cause: 'crash',
|
|
110
|
+
confidence: 'medium',
|
|
111
|
+
detail: `Matched crash pattern: "${crashMatch}"`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { cause: 'unknown', confidence: 'low', detail: 'No patterns matched' };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Check if output matches any pattern in a group.
|
|
118
|
+
* Returns the first matching pattern string, or null.
|
|
119
|
+
*/
|
|
120
|
+
function matchPatterns(output, patterns) {
|
|
121
|
+
for (const pattern of patterns) {
|
|
122
|
+
try {
|
|
123
|
+
if (new RegExp(pattern, 'i').test(output)) {
|
|
124
|
+
return pattern;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// If regex is invalid, try simple string match
|
|
129
|
+
if (output.includes(pattern.toLowerCase())) {
|
|
130
|
+
return pattern;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=QuotaExhaustionDetector.js.map
|
|
@@ -34,16 +34,28 @@ export declare class QuotaTracker {
|
|
|
34
34
|
/**
|
|
35
35
|
* Determine if a job at the given priority should run based on current quota.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
* -
|
|
39
|
-
* -
|
|
40
|
-
* -
|
|
41
|
-
* -
|
|
37
|
+
* Checks BOTH weekly usage AND 5-hour rate limit:
|
|
38
|
+
* - 5-hour >= 95%: block ALL spawns (sessions will immediately fail)
|
|
39
|
+
* - 5-hour >= 80%: only critical priority
|
|
40
|
+
* - Weekly >= shutdown (e.g. 95%): no jobs
|
|
41
|
+
* - Weekly >= critical (e.g. 80%): critical only
|
|
42
|
+
* - Weekly >= elevated (e.g. 60%): high+ only
|
|
43
|
+
* - Weekly >= normal (e.g. 50%): medium+ only
|
|
42
44
|
*
|
|
43
45
|
* If quota data is unavailable or stale, defaults to allowing all jobs
|
|
44
46
|
* (fail-open — better to run than to silently stop).
|
|
45
47
|
*/
|
|
46
48
|
canRunJob(priority: JobPriority): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Check if a session should be spawned at the given priority.
|
|
51
|
+
* Returns a structured result with reason — useful for logging and notifications.
|
|
52
|
+
*
|
|
53
|
+
* Checks both weekly AND 5-hour rate limits.
|
|
54
|
+
*/
|
|
55
|
+
shouldSpawnSession(priority?: JobPriority): {
|
|
56
|
+
allowed: boolean;
|
|
57
|
+
reason: string;
|
|
58
|
+
};
|
|
47
59
|
/**
|
|
48
60
|
* Write a quota state to the file (for collector scripts or manual updates).
|
|
49
61
|
*/
|
|
@@ -52,5 +64,29 @@ export declare class QuotaTracker {
|
|
|
52
64
|
* Get the recommendation string for display purposes.
|
|
53
65
|
*/
|
|
54
66
|
getRecommendation(): QuotaState['recommendation'];
|
|
67
|
+
/**
|
|
68
|
+
* Fetch quota status from a remote API (e.g., Dawn's /api/instar/quota).
|
|
69
|
+
* If the remote says canProceed=false, updates local state accordingly.
|
|
70
|
+
*
|
|
71
|
+
* This allows Instar agents to check a central quota authority before
|
|
72
|
+
* spawning sessions, preventing wasted attempts on exhausted machines.
|
|
73
|
+
*
|
|
74
|
+
* @param url - Full URL to the quota API (e.g., "https://dawn.bot-me.ai/api/instar/quota")
|
|
75
|
+
* @param apiKey - Authorization token (sent as Bearer header)
|
|
76
|
+
* @param timeoutMs - Request timeout (default 5000ms)
|
|
77
|
+
* @returns Remote quota status, or null on failure (fail-open)
|
|
78
|
+
*/
|
|
79
|
+
fetchRemoteQuota(url: string, apiKey: string, timeoutMs?: number): Promise<RemoteQuotaResult | null>;
|
|
80
|
+
}
|
|
81
|
+
/** Result from a remote quota API (e.g., /api/instar/quota) */
|
|
82
|
+
export interface RemoteQuotaResult {
|
|
83
|
+
canProceed: boolean;
|
|
84
|
+
blockReason?: string | null;
|
|
85
|
+
activeAccount?: string | null;
|
|
86
|
+
weeklyPercent: number;
|
|
87
|
+
fiveHourPercent?: number | null;
|
|
88
|
+
canRunPriority?: string;
|
|
89
|
+
recommendation?: string | null;
|
|
90
|
+
stale?: boolean;
|
|
55
91
|
}
|
|
56
92
|
//# sourceMappingURL=QuotaTracker.d.ts.map
|
|
@@ -60,36 +60,70 @@ export class QuotaTracker {
|
|
|
60
60
|
/**
|
|
61
61
|
* Determine if a job at the given priority should run based on current quota.
|
|
62
62
|
*
|
|
63
|
-
*
|
|
64
|
-
* -
|
|
65
|
-
* -
|
|
66
|
-
* -
|
|
67
|
-
* -
|
|
63
|
+
* Checks BOTH weekly usage AND 5-hour rate limit:
|
|
64
|
+
* - 5-hour >= 95%: block ALL spawns (sessions will immediately fail)
|
|
65
|
+
* - 5-hour >= 80%: only critical priority
|
|
66
|
+
* - Weekly >= shutdown (e.g. 95%): no jobs
|
|
67
|
+
* - Weekly >= critical (e.g. 80%): critical only
|
|
68
|
+
* - Weekly >= elevated (e.g. 60%): high+ only
|
|
69
|
+
* - Weekly >= normal (e.g. 50%): medium+ only
|
|
68
70
|
*
|
|
69
71
|
* If quota data is unavailable or stale, defaults to allowing all jobs
|
|
70
72
|
* (fail-open — better to run than to silently stop).
|
|
71
73
|
*/
|
|
72
74
|
canRunJob(priority) {
|
|
75
|
+
const result = this.shouldSpawnSession(priority);
|
|
76
|
+
return result.allowed;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if a session should be spawned at the given priority.
|
|
80
|
+
* Returns a structured result with reason — useful for logging and notifications.
|
|
81
|
+
*
|
|
82
|
+
* Checks both weekly AND 5-hour rate limits.
|
|
83
|
+
*/
|
|
84
|
+
shouldSpawnSession(priority) {
|
|
73
85
|
const state = this.getState();
|
|
74
86
|
if (!state)
|
|
75
|
-
return true
|
|
87
|
+
return { allowed: true, reason: 'No quota data — fail open' };
|
|
88
|
+
// Check 5-hour rate limit first — these cause immediate session failures
|
|
89
|
+
const fiveHour = state.fiveHourPercent;
|
|
90
|
+
if (typeof fiveHour === 'number' && isFinite(fiveHour)) {
|
|
91
|
+
if (fiveHour >= 95) {
|
|
92
|
+
return { allowed: false, reason: `5-hour rate limit at ${fiveHour}% — sessions will fail immediately` };
|
|
93
|
+
}
|
|
94
|
+
if (fiveHour >= 80 && priority && priority !== 'critical') {
|
|
95
|
+
return { allowed: false, reason: `5-hour rate limit at ${fiveHour}% — only critical priority allowed` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Check weekly usage
|
|
76
99
|
const rawUsage = state.usagePercent;
|
|
77
|
-
if (typeof rawUsage !== 'number' || !isFinite(rawUsage))
|
|
78
|
-
return true
|
|
100
|
+
if (typeof rawUsage !== 'number' || !isFinite(rawUsage)) {
|
|
101
|
+
return { allowed: true, reason: 'Invalid weekly data — fail open' };
|
|
102
|
+
}
|
|
79
103
|
const usage = Math.max(0, Math.min(100, rawUsage));
|
|
80
104
|
const { normal, elevated, critical, shutdown } = this.config.thresholds;
|
|
81
|
-
if (usage >= shutdown)
|
|
82
|
-
return false
|
|
105
|
+
if (usage >= shutdown) {
|
|
106
|
+
return { allowed: false, reason: `Weekly quota at ${usage}% — all jobs stopped` };
|
|
107
|
+
}
|
|
83
108
|
if (usage >= critical) {
|
|
84
|
-
|
|
109
|
+
const ok = !priority || priority === 'critical';
|
|
110
|
+
return ok
|
|
111
|
+
? { allowed: true, reason: `Weekly at ${usage}% — critical only` }
|
|
112
|
+
: { allowed: false, reason: `Weekly quota at ${usage}% — only critical priority runs` };
|
|
85
113
|
}
|
|
86
114
|
if (usage >= elevated) {
|
|
87
|
-
|
|
115
|
+
const ok = !priority || priority === 'critical' || priority === 'high';
|
|
116
|
+
return ok
|
|
117
|
+
? { allowed: true, reason: `Weekly at ${usage}% — high+ only` }
|
|
118
|
+
: { allowed: false, reason: `Weekly quota at ${usage}% — only high+ priority runs` };
|
|
88
119
|
}
|
|
89
120
|
if (usage >= normal) {
|
|
90
|
-
|
|
121
|
+
const ok = !priority || priority !== 'low';
|
|
122
|
+
return ok
|
|
123
|
+
? { allowed: true, reason: `Weekly at ${usage}% — medium+ only` }
|
|
124
|
+
: { allowed: false, reason: `Weekly quota at ${usage}% — low priority paused` };
|
|
91
125
|
}
|
|
92
|
-
return true
|
|
126
|
+
return { allowed: true, reason: 'Quota normal' };
|
|
93
127
|
}
|
|
94
128
|
/**
|
|
95
129
|
* Write a quota state to the file (for collector scripts or manual updates).
|
|
@@ -126,6 +160,11 @@ export class QuotaTracker {
|
|
|
126
160
|
const state = this.getState();
|
|
127
161
|
if (!state)
|
|
128
162
|
return 'normal';
|
|
163
|
+
// 5-hour at 95%+ is always 'stop' regardless of weekly
|
|
164
|
+
if (typeof state.fiveHourPercent === 'number' && state.fiveHourPercent >= 95)
|
|
165
|
+
return 'stop';
|
|
166
|
+
if (typeof state.fiveHourPercent === 'number' && state.fiveHourPercent >= 80)
|
|
167
|
+
return 'critical';
|
|
129
168
|
const usage = state.usagePercent;
|
|
130
169
|
const { normal, elevated, critical, shutdown } = this.config.thresholds;
|
|
131
170
|
if (usage >= shutdown)
|
|
@@ -138,5 +177,55 @@ export class QuotaTracker {
|
|
|
138
177
|
return 'reduce';
|
|
139
178
|
return 'normal';
|
|
140
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Fetch quota status from a remote API (e.g., Dawn's /api/instar/quota).
|
|
182
|
+
* If the remote says canProceed=false, updates local state accordingly.
|
|
183
|
+
*
|
|
184
|
+
* This allows Instar agents to check a central quota authority before
|
|
185
|
+
* spawning sessions, preventing wasted attempts on exhausted machines.
|
|
186
|
+
*
|
|
187
|
+
* @param url - Full URL to the quota API (e.g., "https://dawn.bot-me.ai/api/instar/quota")
|
|
188
|
+
* @param apiKey - Authorization token (sent as Bearer header)
|
|
189
|
+
* @param timeoutMs - Request timeout (default 5000ms)
|
|
190
|
+
* @returns Remote quota status, or null on failure (fail-open)
|
|
191
|
+
*/
|
|
192
|
+
async fetchRemoteQuota(url, apiKey, timeoutMs = 5000) {
|
|
193
|
+
try {
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
196
|
+
const response = await fetch(url, {
|
|
197
|
+
headers: {
|
|
198
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
199
|
+
'Accept': 'application/json',
|
|
200
|
+
},
|
|
201
|
+
signal: controller.signal,
|
|
202
|
+
});
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
console.warn(`[quota] Remote quota API returned ${response.status}`);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const data = await response.json();
|
|
209
|
+
// If remote says blocked, update local state to reflect it
|
|
210
|
+
if (!data.canProceed && typeof data.weeklyPercent === 'number') {
|
|
211
|
+
const state = this.getState() ?? {
|
|
212
|
+
usagePercent: 0,
|
|
213
|
+
lastUpdated: new Date().toISOString(),
|
|
214
|
+
};
|
|
215
|
+
state.usagePercent = data.weeklyPercent;
|
|
216
|
+
if (typeof data.fiveHourPercent === 'number') {
|
|
217
|
+
state.fiveHourPercent = data.fiveHourPercent;
|
|
218
|
+
}
|
|
219
|
+
state.lastUpdated = new Date().toISOString();
|
|
220
|
+
this.cachedState = state;
|
|
221
|
+
}
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
// Network failure, timeout, etc. — fail open
|
|
226
|
+
console.warn(`[quota] Remote quota check failed: ${err instanceof Error ? err.message : err}`);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
141
230
|
}
|
|
142
231
|
//# sourceMappingURL=QuotaTracker.js.map
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { SkipLedger } from './SkipLedger.js';
|
|
11
11
|
import type { SessionManager } from '../core/SessionManager.js';
|
|
12
12
|
import type { StateManager } from '../core/StateManager.js';
|
|
13
|
+
import type { QuotaTracker } from '../monitoring/QuotaTracker.js';
|
|
13
14
|
import type { MessagingAdapter } from '../core/types.js';
|
|
14
15
|
import type { JobDefinition, JobSchedulerConfig, JobPriority } from '../core/types.js';
|
|
15
16
|
import type { TelegramAdapter } from '../messaging/TelegramAdapter.js';
|
|
@@ -42,6 +43,8 @@ export declare class JobScheduler {
|
|
|
42
43
|
private messenger;
|
|
43
44
|
/** Optional Telegram adapter for job-topic coupling */
|
|
44
45
|
private telegram;
|
|
46
|
+
/** Optional quota tracker for death classification cross-reference */
|
|
47
|
+
private quotaTracker;
|
|
45
48
|
constructor(config: JobSchedulerConfig, sessionManager: SessionManager, state: StateManager, stateDir: string);
|
|
46
49
|
/**
|
|
47
50
|
* Set a messaging adapter for job completion notifications.
|
|
@@ -52,6 +55,12 @@ export declare class JobScheduler {
|
|
|
52
55
|
* Every job gets its own topic — the user's window into the job.
|
|
53
56
|
*/
|
|
54
57
|
setTelegram(adapter: TelegramAdapter): void;
|
|
58
|
+
/**
|
|
59
|
+
* Set the quota tracker for session death classification.
|
|
60
|
+
* When set, session deaths are cross-referenced with quota state
|
|
61
|
+
* to determine if they died from quota exhaustion.
|
|
62
|
+
*/
|
|
63
|
+
setQuotaTracker(tracker: QuotaTracker): void;
|
|
55
64
|
/**
|
|
56
65
|
* Start the scheduler — load jobs, set up cron tasks, check for missed jobs.
|
|
57
66
|
*/
|
|
@@ -11,6 +11,7 @@ import { Cron } from 'croner';
|
|
|
11
11
|
import { execFileSync } from 'node:child_process';
|
|
12
12
|
import { loadJobs } from './JobLoader.js';
|
|
13
13
|
import { SkipLedger } from './SkipLedger.js';
|
|
14
|
+
import { classifySessionDeath } from '../monitoring/QuotaExhaustionDetector.js';
|
|
14
15
|
const PRIORITY_ORDER = {
|
|
15
16
|
critical: 0,
|
|
16
17
|
high: 1,
|
|
@@ -33,6 +34,8 @@ export class JobScheduler {
|
|
|
33
34
|
messenger = null;
|
|
34
35
|
/** Optional Telegram adapter for job-topic coupling */
|
|
35
36
|
telegram = null;
|
|
37
|
+
/** Optional quota tracker for death classification cross-reference */
|
|
38
|
+
quotaTracker = null;
|
|
36
39
|
constructor(config, sessionManager, state, stateDir) {
|
|
37
40
|
this.config = config;
|
|
38
41
|
this.sessionManager = sessionManager;
|
|
@@ -52,6 +55,14 @@ export class JobScheduler {
|
|
|
52
55
|
setTelegram(adapter) {
|
|
53
56
|
this.telegram = adapter;
|
|
54
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Set the quota tracker for session death classification.
|
|
60
|
+
* When set, session deaths are cross-referenced with quota state
|
|
61
|
+
* to determine if they died from quota exhaustion.
|
|
62
|
+
*/
|
|
63
|
+
setQuotaTracker(tracker) {
|
|
64
|
+
this.quotaTracker = tracker;
|
|
65
|
+
}
|
|
55
66
|
/**
|
|
56
67
|
* Start the scheduler — load jobs, set up cron tasks, check for missed jobs.
|
|
57
68
|
*/
|
|
@@ -353,6 +364,24 @@ export class JobScheduler {
|
|
|
353
364
|
catch {
|
|
354
365
|
// Session may already be dead — that's fine
|
|
355
366
|
}
|
|
367
|
+
// Classify death cause if session failed/was killed
|
|
368
|
+
let deathCause;
|
|
369
|
+
if (failed && output) {
|
|
370
|
+
const quotaState = this.quotaTracker?.getState() ?? null;
|
|
371
|
+
const classification = classifySessionDeath(output, quotaState);
|
|
372
|
+
deathCause = classification.cause;
|
|
373
|
+
this.state.appendEvent({
|
|
374
|
+
type: 'session_death_classified',
|
|
375
|
+
summary: `Session for "${job.slug}" classified as ${classification.cause} (${classification.confidence}): ${classification.detail}`,
|
|
376
|
+
timestamp: new Date().toISOString(),
|
|
377
|
+
metadata: {
|
|
378
|
+
slug: job.slug,
|
|
379
|
+
cause: classification.cause,
|
|
380
|
+
confidence: classification.confidence,
|
|
381
|
+
detail: classification.detail,
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|
|
356
385
|
// Build a summary message
|
|
357
386
|
const duration = session.startedAt
|
|
358
387
|
? Math.round((Date.now() - new Date(session.startedAt).getTime()) / 1000)
|
|
@@ -361,7 +390,10 @@ export class JobScheduler {
|
|
|
361
390
|
? `${Math.floor(duration / 60)}m ${duration % 60}s`
|
|
362
391
|
: `${duration}s`;
|
|
363
392
|
let summary = `*Job Complete: ${job.name}*\n`;
|
|
364
|
-
summary += `Status: ${failed ? 'Failed' : 'Done'}
|
|
393
|
+
summary += `Status: ${failed ? 'Failed' : 'Done'}`;
|
|
394
|
+
if (deathCause && deathCause !== 'unknown')
|
|
395
|
+
summary += ` (${deathCause})`;
|
|
396
|
+
summary += '\n';
|
|
365
397
|
if (duration > 0)
|
|
366
398
|
summary += `Duration: ${durationStr}\n`;
|
|
367
399
|
if (output) {
|