@nairon-ai/aegis 0.6.2 → 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.
Files changed (46) hide show
  1. package/README.md +26 -0
  2. package/dist-node/agent/client.d.ts +8 -0
  3. package/dist-node/agent/client.d.ts.map +1 -1
  4. package/dist-node/agent/client.js +38 -5
  5. package/dist-node/agent/client.js.map +1 -1
  6. package/dist-node/cli/commands/deploy.js +1 -0
  7. package/dist-node/cli/commands/deploy.js.map +1 -1
  8. package/dist-node/cli/commands/init.d.ts.map +1 -1
  9. package/dist-node/cli/commands/init.js +9 -0
  10. package/dist-node/cli/commands/init.js.map +1 -1
  11. package/dist-node/cli/commands/pause.d.ts +27 -0
  12. package/dist-node/cli/commands/pause.d.ts.map +1 -0
  13. package/dist-node/cli/commands/pause.js +114 -0
  14. package/dist-node/cli/commands/pause.js.map +1 -0
  15. package/dist-node/cli/commands/status.d.ts.map +1 -1
  16. package/dist-node/cli/commands/status.js +2 -0
  17. package/dist-node/cli/commands/status.js.map +1 -1
  18. package/dist-node/cli/index.js +3 -0
  19. package/dist-node/cli/index.js.map +1 -1
  20. package/dist-node/cli/state.d.ts.map +1 -1
  21. package/dist-node/cli/state.js +22 -0
  22. package/dist-node/cli/state.js.map +1 -1
  23. package/dist-node/core/runs.d.ts.map +1 -1
  24. package/dist-node/core/runs.js +8 -4
  25. package/dist-node/core/runs.js.map +1 -1
  26. package/dist-node/server/app.d.ts.map +1 -1
  27. package/dist-node/server/app.js +101 -25
  28. package/dist-node/server/app.js.map +1 -1
  29. package/dist-node/shared/config.d.ts.map +1 -1
  30. package/dist-node/shared/config.js +1 -0
  31. package/dist-node/shared/config.js.map +1 -1
  32. package/dist-node/shared/types.d.ts +1 -0
  33. package/dist-node/shared/types.d.ts.map +1 -1
  34. package/docs/SETUP.md +29 -0
  35. package/package.json +1 -1
  36. package/src/agent/client.ts +46 -8
  37. package/src/cli/commands/deploy.ts +2 -0
  38. package/src/cli/commands/init.ts +9 -0
  39. package/src/cli/commands/pause.ts +127 -0
  40. package/src/cli/commands/status.ts +2 -0
  41. package/src/cli/index.ts +3 -0
  42. package/src/cli/state.ts +24 -0
  43. package/src/core/runs.ts +7 -6
  44. package/src/server/app.ts +97 -31
  45. package/src/shared/config.ts +1 -0
  46. package/src/shared/types.ts +1 -0
@@ -0,0 +1,127 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { defineCommand } from "citty";
5
+ import { consola } from "consola";
6
+ import { resolvePackageBinary } from "../binaries.js";
7
+ import { findPackageRoot, resolveEnvFileForWrite } from "../paths.js";
8
+
9
+ export const pauseCommand = defineCommand({
10
+ meta: { name: "pause", description: "Pause Aegis so it cannot pick up new work" },
11
+ args: {
12
+ "local-only": {
13
+ type: "boolean",
14
+ description: "Only update .aegis/.env; do not update the deployed Worker",
15
+ default: false,
16
+ },
17
+ },
18
+ async run({ args }) {
19
+ await runPause({ localOnly: Boolean(args["local-only"]) });
20
+ },
21
+ });
22
+
23
+ export const resumeCommand = defineCommand({
24
+ meta: { name: "resume", description: "Resume Aegis after a pause" },
25
+ args: {
26
+ concurrency: {
27
+ type: "string",
28
+ description: "How many tickets Aegis may claim per pickup cycle",
29
+ default: "1",
30
+ },
31
+ "local-only": {
32
+ type: "boolean",
33
+ description: "Only update .aegis/.env; do not update the deployed Worker",
34
+ default: false,
35
+ },
36
+ },
37
+ async run({ args }) {
38
+ await runResume({
39
+ concurrency: parseConcurrency(String(args.concurrency ?? "1")),
40
+ localOnly: Boolean(args["local-only"]),
41
+ });
42
+ },
43
+ });
44
+
45
+ export async function runPause(options: { localOnly?: boolean } = {}): Promise<void> {
46
+ setLocalEnvValue("MAX_CONCURRENT_RUNS", "0");
47
+ consola.success("Saved local pause: MAX_CONCURRENT_RUNS=0");
48
+ if (!options.localOnly) {
49
+ pushWorkerSecret("MAX_CONCURRENT_RUNS", "0");
50
+ }
51
+
52
+ consola.success("Aegis is paused.");
53
+ consola.log("It will not claim tickets, move tickets, run stale recovery, or start sandboxes.");
54
+ consola.log("Resume with `aegis resume`.");
55
+ }
56
+
57
+ export async function runResume(
58
+ options: { concurrency?: number; localOnly?: boolean } = {},
59
+ ): Promise<void> {
60
+ const concurrency = options.concurrency ?? 1;
61
+ setLocalEnvValue("MAX_CONCURRENT_RUNS", String(concurrency));
62
+ consola.success(`Saved local resume: MAX_CONCURRENT_RUNS=${concurrency}`);
63
+ if (!options.localOnly) {
64
+ pushWorkerSecret("MAX_CONCURRENT_RUNS", String(concurrency));
65
+ }
66
+
67
+ consola.success("Aegis is resumed.");
68
+ consola.log(`Aegis may claim up to ${concurrency} ticket(s) per pickup cycle.`);
69
+ }
70
+
71
+ function pushWorkerSecret(key: string, value: string): void {
72
+ const packageRoot = findPackageRoot(import.meta.url);
73
+ const wranglerBin = resolvePackageBinary(packageRoot, "wrangler");
74
+ const wranglerConfig = resolve(packageRoot, "wrangler.jsonc");
75
+
76
+ consola.start(`Updating deployed Worker: ${key}=${value}`);
77
+ const result = spawnSync(wranglerBin, ["secret", "put", key, "--config", wranglerConfig], {
78
+ cwd: packageRoot,
79
+ input: value,
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ encoding: "utf-8",
82
+ });
83
+
84
+ if (result.status !== 0) {
85
+ throw new Error(
86
+ [
87
+ `Could not update the deployed Worker secret ${key}.`,
88
+ "Run `npx wrangler login`, then rerun this command.",
89
+ "Your local .aegis/.env was still updated.",
90
+ result.stderr || result.stdout,
91
+ ]
92
+ .filter(Boolean)
93
+ .join("\n"),
94
+ );
95
+ }
96
+ consola.success("Deployed Worker updated. No Docker rebuild needed.");
97
+ }
98
+
99
+ function parseConcurrency(value: string): number {
100
+ const parsed = Number.parseInt(value, 10);
101
+ if (!Number.isFinite(parsed) || parsed < 1) {
102
+ throw new Error("Resume concurrency must be 1 or higher.");
103
+ }
104
+ return parsed;
105
+ }
106
+
107
+ function setLocalEnvValue(key: string, value: string): void {
108
+ const envFile = resolveEnvFileForWrite();
109
+ const lines = existsSync(envFile)
110
+ ? readFileSync(envFile, "utf-8").replace(/\r/g, "").split("\n")
111
+ : ["# Aegis configuration. Do not commit this file."];
112
+ const nextLine = `${key}=${value}`;
113
+ let replaced = false;
114
+ const next = lines.map((line) => {
115
+ if (line.startsWith(`${key}=`)) {
116
+ replaced = true;
117
+ return nextLine;
118
+ }
119
+ return line;
120
+ });
121
+ if (!replaced) {
122
+ if (next.length && next[next.length - 1] !== "") next.push("");
123
+ next.push(nextLine);
124
+ }
125
+ mkdirSync(dirname(envFile), { recursive: true });
126
+ writeFileSync(envFile, `${next.join("\n").replace(/\n+$/, "")}\n`);
127
+ }
@@ -16,6 +16,8 @@ export async function runStatus(): Promise<void> {
16
16
  printState(state);
17
17
  if (state.config.monitoredRepo) consola.log(` Repo: ${state.config.monitoredRepo}`);
18
18
  if (state.config.workerUrl) consola.log(` Worker: ${state.config.workerUrl}`);
19
+ const concurrency = state.config.maxConcurrentRuns ?? 1;
20
+ consola.log(` Pickup: ${concurrency === 0 ? "paused" : `enabled (${concurrency} max/cycle)`}`);
19
21
  const secretsSummary = formatGitHubSecretsSummary(
20
22
  inspectGitHubActionsSecrets(state.config.monitoredRepo),
21
23
  );
package/src/cli/index.ts CHANGED
@@ -7,6 +7,7 @@ import { deployCommand } from "./commands/deploy.js";
7
7
  import { disconnectCommand } from "./commands/disconnect.js";
8
8
  import { initCommand } from "./commands/init.js";
9
9
  import { logsCommand } from "./commands/logs.js";
10
+ import { pauseCommand, resumeCommand } from "./commands/pause.js";
10
11
  import { peekCommand } from "./commands/peek.js";
11
12
  import { pickupCommand } from "./commands/pickup.js";
12
13
  import { recoverCommand } from "./commands/recover.js";
@@ -30,6 +31,8 @@ const main = defineCommand({
30
31
  peek: peekCommand,
31
32
  logs: logsCommand,
32
33
  recover: recoverCommand,
34
+ pause: pauseCommand,
35
+ resume: resumeCommand,
33
36
  status: statusCommand,
34
37
  deploy: deployCommand,
35
38
  disconnect: disconnectCommand,
package/src/cli/state.ts CHANGED
@@ -46,6 +46,9 @@ function loadConfigFileInto(config: AegisCliConfig, envFile: string): void {
46
46
  case "CONTEXT_PROFILE":
47
47
  config.contextProfile = value as AegisCliConfig["contextProfile"];
48
48
  break;
49
+ case "MAX_CONCURRENT_RUNS":
50
+ config.maxConcurrentRuns = parseConfigInteger(value);
51
+ break;
49
52
  case "READY_LABEL":
50
53
  config.readyLabel = value;
51
54
  break;
@@ -146,6 +149,7 @@ export function saveConfig(config: AegisCliConfig): void {
146
149
  add("BASE_BRANCH", config.baseBranch);
147
150
  add("AUTOMATION_MODE", config.automationMode);
148
151
  add("CONTEXT_PROFILE", config.contextProfile);
152
+ add("MAX_CONCURRENT_RUNS", formatConfigInteger(config.maxConcurrentRuns));
149
153
  add("READY_LABEL", config.readyLabel);
150
154
  add("BUG_LABEL", config.bugLabel);
151
155
  add("GITHUB_APP_ID", config.githubAppId);
@@ -208,6 +212,7 @@ export function printState(state: SetupState): void {
208
212
  check(state.telegram, "Telegram approvals configured (optional)");
209
213
  check(state.production, "Production context configured (optional)");
210
214
  check(state.worker, "Worker URL saved");
215
+ check((state.config.maxConcurrentRuns ?? 1) > 0, "Aegis pickup enabled");
211
216
  consola.log("");
212
217
  }
213
218
 
@@ -225,6 +230,14 @@ export async function runInteractiveMenu(): Promise<void> {
225
230
  const { runRecover } = await import("./commands/recover.js");
226
231
  await runRecover({ olderThanMinutes: 20 });
227
232
  }
233
+ if (choice === "pause") {
234
+ const { runPause } = await import("./commands/pause.js");
235
+ await runPause();
236
+ }
237
+ if (choice === "resume") {
238
+ const { runResume } = await import("./commands/pause.js");
239
+ await runResume({ concurrency: 1 });
240
+ }
228
241
  if (choice === "setup") {
229
242
  const { runSetupWizard } = await import("./commands/setup.js");
230
243
  await runSetupWizard();
@@ -252,6 +265,8 @@ async function promptMenuChoice(): Promise<string | undefined> {
252
265
  { label: "Find ready bugs (dry run)", value: "pickup" },
253
266
  { label: "View active runs", value: "runs" },
254
267
  { label: "Recover stale runs", value: "recover" },
268
+ { label: "Pause Aegis", value: "pause" },
269
+ { label: "Resume Aegis", value: "resume" },
255
270
  { label: "Run setup", value: "setup" },
256
271
  { label: "View status", value: "status" },
257
272
  { label: "Deploy worker", value: "deploy" },
@@ -267,3 +282,12 @@ async function promptMenuChoice(): Promise<string | undefined> {
267
282
  throw error;
268
283
  }
269
284
  }
285
+
286
+ function parseConfigInteger(value: string): number | undefined {
287
+ const parsed = Number.parseInt(value, 10);
288
+ return Number.isFinite(parsed) ? parsed : undefined;
289
+ }
290
+
291
+ function formatConfigInteger(value: number | undefined): string | undefined {
292
+ return typeof value === "number" ? String(value) : undefined;
293
+ }
package/src/core/runs.ts CHANGED
@@ -1,9 +1,8 @@
1
+ import { blockRunWithNotification } from "../agent/client.js";
1
2
  import { GitHubClient } from "../integrations/github.js";
2
3
  import { LinearClient } from "../integrations/linear.js";
3
- import { sendTelegramMessage } from "../integrations/telegram.js";
4
4
  import { runFromItem } from "../shared/run-state.js";
5
5
  import type { AegisConfig, RunSnapshot, WorkItem, WorkItemStatus } from "../shared/types.js";
6
- import { sourceFor } from "../sources/index.js";
7
6
 
8
7
  const ACTIVE_STATUSES: WorkItemStatus[] = ["claimed", "planning", "implementing"];
9
8
  const TRACKED_STATUSES: WorkItemStatus[] = [
@@ -70,11 +69,13 @@ export async function recoverStaleRuns(
70
69
  }
71
70
 
72
71
  const message = staleRunMessage(snapshot.run.id, options.olderThanMinutes);
73
- await sourceFor(config, snapshot.item.source).markBlocked(snapshot.item, message);
74
- await sendTelegramMessage(
72
+ await blockRunWithNotification({
75
73
  config,
76
- `Aegis stopped ${snapshot.item.identifier}: it did not finish an RCA within ${options.olderThanMinutes} minutes.\n\nRun: ${snapshot.run.id}\nTicket: ${snapshot.item.url}`,
77
- );
74
+ item: snapshot.item,
75
+ run: snapshot.run,
76
+ trackerMessage: message,
77
+ telegramMessage: `Aegis stopped ${snapshot.item.identifier}: it did not finish an RCA within ${options.olderThanMinutes} minutes.\n\nRun: ${snapshot.run.id}\nTicket: ${snapshot.item.url}`,
78
+ });
78
79
  results.push({
79
80
  runId: snapshot.run.id,
80
81
  identifier: snapshot.item.identifier,
package/src/server/app.ts CHANGED
@@ -1,15 +1,11 @@
1
1
  import { Hono } from "hono";
2
- import { startImplementationRun, startPlanRun } from "../agent/client.js";
2
+ import { blockRunWithNotification, startImplementationRun, startPlanRun } from "../agent/client.js";
3
3
  import type { BugFixAgentCaller } from "../agent/client.js";
4
4
  import { pickupReadyBugs, scanReadyBugs } from "../core/pickup.js";
5
5
  import { listRunSnapshots, recoverStaleRuns } from "../core/runs.js";
6
6
  import { GitHubClient } from "../integrations/github.js";
7
7
  import { LinearClient } from "../integrations/linear.js";
8
- import {
9
- parseAegisCommand,
10
- parseTelegramUpdate,
11
- sendTelegramMessage,
12
- } from "../integrations/telegram.js";
8
+ import { parseAegisCommand, parseTelegramUpdate } from "../integrations/telegram.js";
13
9
  import { verifyGitHubWebhook, verifyLinearWebhook } from "../integrations/webhooks.js";
14
10
  import { configFromBindings } from "../shared/config.js";
15
11
  import { formatReadyDecisionLine } from "../shared/format.js";
@@ -21,7 +17,6 @@ import type {
21
17
  WorkItem,
22
18
  WorkRun,
23
19
  } from "../shared/types.js";
24
- import { sourceFor } from "../sources/index.js";
25
20
 
26
21
  type HonoBindings = { Bindings: Bindings };
27
22
  type AegisAppOptions = {
@@ -39,6 +34,16 @@ export function createAegisApp(options: AegisAppOptions = {}) {
39
34
  return c.json({ service: "aegis", status: "ok", product: "AFK bug-fixing agent" });
40
35
  });
41
36
 
37
+ app.get("/api/status", (c) => {
38
+ const config = { ...configFromBindings(c.env), workerUrl: new URL(c.req.url).origin };
39
+ return c.json({
40
+ service: "aegis",
41
+ paused: isPaused(config),
42
+ maxConcurrentRuns: config.maxConcurrentRuns,
43
+ repo: config.monitoredRepo,
44
+ });
45
+ });
46
+
42
47
  app.get("/api/pickup", async (c) => {
43
48
  const config = { ...configFromBindings(c.env), workerUrl: new URL(c.req.url).origin };
44
49
  const decisions = await scanReadyBugs(config);
@@ -59,6 +64,14 @@ export function createAegisApp(options: AegisAppOptions = {}) {
59
64
 
60
65
  app.post("/api/pickup", async (c) => {
61
66
  const config = { ...configFromBindings(c.env), workerUrl: new URL(c.req.url).origin };
67
+ if (isPaused(config)) {
68
+ return c.json({
69
+ paused: true,
70
+ count: 0,
71
+ decisions: [],
72
+ message: "Aegis is paused. Run `aegis resume` to allow pickup again.",
73
+ });
74
+ }
62
75
  const recovered = await recoverStaleRuns(config, {
63
76
  olderThanMinutes: staleRunMinutes(c.env),
64
77
  });
@@ -101,6 +114,13 @@ export function createAegisApp(options: AegisAppOptions = {}) {
101
114
 
102
115
  app.post("/api/runs/recover", async (c) => {
103
116
  const config = { ...configFromBindings(c.env), workerUrl: new URL(c.req.url).origin };
117
+ if (isPaused(config)) {
118
+ return c.json({
119
+ paused: true,
120
+ results: [],
121
+ message: "Aegis is paused. Stale-run recovery is disabled.",
122
+ });
123
+ }
104
124
  const results = await recoverStaleRuns(config, {
105
125
  olderThanMinutes: staleRunMinutes(c.env),
106
126
  });
@@ -131,7 +151,6 @@ export function createAegisApp(options: AegisAppOptions = {}) {
131
151
  }
132
152
  if (command.action === "stop") {
133
153
  await stopRun(c.env, command.runId);
134
- await sendTelegramMessage(config, `Stopped Aegis run ${command.runId}.`);
135
154
  return c.json({ accepted: true });
136
155
  }
137
156
  await askRun(
@@ -164,6 +183,7 @@ export function createAegisApp(options: AegisAppOptions = {}) {
164
183
  event === "issues" &&
165
184
  ["opened", "edited", "labeled", "unlabeled", "reopened"].includes(payload.action ?? "")
166
185
  ) {
186
+ if (isPaused(config)) return c.json({ accepted: true, paused: true });
167
187
  c.executionCtx.waitUntil(
168
188
  pickupFromWebhook(c.env, new URL(c.req.url).origin, options.callBugFixAgent),
169
189
  );
@@ -207,6 +227,7 @@ export function createAegisApp(options: AegisAppOptions = {}) {
207
227
  }
208
228
  if (payload.type !== "Comment") {
209
229
  if (payload.type === "Issue") {
230
+ if (isPaused(config)) return c.json({ accepted: true, paused: true });
210
231
  c.executionCtx.waitUntil(
211
232
  pickupFromWebhook(c.env, new URL(c.req.url).origin, options.callBugFixAgent),
212
233
  );
@@ -235,6 +256,10 @@ export async function scheduledPickup(
235
256
  callAgent?: BugFixAgentCaller,
236
257
  ): Promise<void> {
237
258
  const config = configFromBindings(env);
259
+ if (isPaused(config)) {
260
+ console.log("[aegis] scheduled pickup skipped because Aegis is paused");
261
+ return;
262
+ }
238
263
  const recovered = await recoverStaleRuns(config, { olderThanMinutes: staleRunMinutes(env) });
239
264
  const blocked = recovered.filter((result) => result.status === "blocked");
240
265
  if (blocked.length) {
@@ -304,11 +329,13 @@ async function startPlanSafely(
304
329
  : error instanceof Error
305
330
  ? error.message
306
331
  : String(error);
307
- await sourceFor(config, item.source).markBlocked(item, `Aegis failed to run: ${message}`);
308
- await sendTelegramMessage(
332
+ await blockRunWithNotification({
309
333
  config,
310
- `Aegis stopped ${item.identifier}: ${message}\n\nRun: ${run.id}\nTicket: ${item.url}`,
311
- );
334
+ item,
335
+ run,
336
+ trackerMessage: `Aegis failed to run: ${message}`,
337
+ telegramMessage: `Aegis stopped ${item.identifier}: ${message}\n\nRun: ${run.id}\nTicket: ${item.url}`,
338
+ });
312
339
  }
313
340
  }
314
341
 
@@ -322,6 +349,7 @@ async function pickupFromWebhook(
322
349
  ...configFromBindings(env),
323
350
  workerUrl: configFromBindings(env).workerUrl ?? origin,
324
351
  };
352
+ if (isPaused(config)) return;
325
353
  await pickupReadyBugs({
326
354
  config,
327
355
  startRun: async (item, run, decision) => {
@@ -341,15 +369,26 @@ async function proceedRun(
341
369
  const config = configFromBindings(env);
342
370
  const resolved = item ?? (await resolveItemFromRunId(config, runId));
343
371
  const run = runFromItem(resolved, "plan-first");
344
- await startImplementationRun({
345
- config,
346
- item: resolved,
347
- run,
348
- approvedBy: actor,
349
- callAgent,
350
- env,
351
- ctx,
352
- });
372
+ try {
373
+ await startImplementationRun({
374
+ config,
375
+ item: resolved,
376
+ run,
377
+ approvedBy: actor,
378
+ callAgent,
379
+ env,
380
+ ctx,
381
+ });
382
+ } catch (error) {
383
+ const message = error instanceof Error ? error.message : String(error);
384
+ await blockRunWithNotification({
385
+ config,
386
+ item: resolved,
387
+ run,
388
+ trackerMessage: `Aegis failed during implementation: ${message}`,
389
+ telegramMessage: `Aegis failed during implementation for ${resolved.identifier}.\n\nReason: ${message}\n\nRun: ${run.id}\nTicket: ${resolved.url}`,
390
+ });
391
+ }
353
392
  }
354
393
 
355
394
  async function askRun(
@@ -371,21 +410,44 @@ async function askRun(
371
410
  Human follow-up from ${actor}:
372
411
  ${text}`,
373
412
  };
374
- await startPlanRun({
375
- config,
376
- item: followUpItem,
377
- run: runFromItem(resolved, "plan-first"),
378
- decision: { ready: true, reason: "human follow-up", risk: "low", autoImplementAllowed: false },
379
- callAgent,
380
- env,
381
- ctx,
382
- });
413
+ const run = runFromItem(resolved, "plan-first");
414
+ try {
415
+ await startPlanRun({
416
+ config,
417
+ item: followUpItem,
418
+ run,
419
+ decision: {
420
+ ready: true,
421
+ reason: "human follow-up",
422
+ risk: "low",
423
+ autoImplementAllowed: false,
424
+ },
425
+ callAgent,
426
+ env,
427
+ ctx,
428
+ });
429
+ } catch (error) {
430
+ const message = error instanceof Error ? error.message : String(error);
431
+ await blockRunWithNotification({
432
+ config,
433
+ item: resolved,
434
+ run,
435
+ trackerMessage: `Aegis failed while handling follow-up context: ${message}`,
436
+ telegramMessage: `Aegis failed while handling follow-up context for ${resolved.identifier}.\n\nReason: ${message}\n\nRun: ${run.id}\nTicket: ${resolved.url}`,
437
+ });
438
+ }
383
439
  }
384
440
 
385
441
  async function stopRun(env: Bindings, runId: string, item?: WorkItem): Promise<void> {
386
442
  const config = configFromBindings(env);
387
443
  const resolved = item ?? (await resolveItemFromRunId(config, runId));
388
- await sourceFor(config, resolved.source).markBlocked(resolved, `Aegis run ${runId} was stopped.`);
444
+ await blockRunWithNotification({
445
+ config,
446
+ item: resolved,
447
+ run: { id: runId },
448
+ trackerMessage: `Aegis run ${runId} was stopped.`,
449
+ telegramMessage: `Aegis run ${runId} was stopped.\n\nTicket: ${resolved.url}`,
450
+ });
389
451
  }
390
452
 
391
453
  async function withTimeout<T>(
@@ -425,6 +487,10 @@ function parsePositiveInteger(value: string | undefined, fallback: number): numb
425
487
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
426
488
  }
427
489
 
490
+ function isPaused(config: { maxConcurrentRuns: number }): boolean {
491
+ return config.maxConcurrentRuns <= 0;
492
+ }
493
+
428
494
  async function resolveItemFromRunId(
429
495
  config: ReturnType<typeof configFromBindings>,
430
496
  runId: string,
@@ -77,6 +77,7 @@ export function configFromCli(cli: AegisCliConfig): AegisConfig {
77
77
  baseBranch: cli.baseBranch,
78
78
  automationMode: cli.automationMode,
79
79
  contextProfile: cli.contextProfile,
80
+ maxConcurrentRuns: cli.maxConcurrentRuns,
80
81
  readyLabel: cli.readyLabel,
81
82
  bugLabel: cli.bugLabel,
82
83
  github: {
@@ -269,6 +269,7 @@ export type AegisCliConfig = {
269
269
  baseBranch?: string;
270
270
  automationMode?: AutomationMode;
271
271
  contextProfile?: ContextProfile;
272
+ maxConcurrentRuns?: number;
272
273
  readyLabel?: string;
273
274
  bugLabel?: string;
274
275
  githubAppId?: string;