bosun 0.42.0 → 0.42.1
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/.env.example +12 -0
- package/README.md +2 -0
- package/agent/agent-pool.mjs +34 -1
- package/agent/agent-work-report.mjs +89 -3
- package/agent/analyze-agent-work-helpers.mjs +14 -0
- package/agent/analyze-agent-work.mjs +23 -3
- package/agent/primary-agent.mjs +142 -0
- package/bosun-tui.mjs +4 -3
- package/bosun.schema.json +1 -1
- package/config/config.mjs +58 -0
- package/config/workspace-health.mjs +36 -6
- package/git/diff-stats.mjs +550 -124
- package/github/github-app-auth.mjs +9 -5
- package/infra/maintenance.mjs +13 -6
- package/infra/monitor.mjs +398 -10
- package/infra/runtime-accumulator.mjs +9 -1
- package/infra/session-tracker.mjs +51 -1
- package/infra/tui-bridge.mjs +415 -0
- package/infra/worktree-recovery-state.mjs +159 -0
- package/kanban/kanban-adapter.mjs +41 -8
- package/package.json +139 -137
- package/server/ui-server.mjs +654 -58
- package/shell/codex-config.mjs +34 -8
- package/task/task-executor.mjs +1 -1
- package/telegram/telegram-bot.mjs +267 -18
- package/tools/vitest-runner.mjs +108 -0
- package/tui/app.mjs +252 -148
- package/tui/components/status-header.mjs +88 -131
- package/tui/lib/ws-bridge.mjs +125 -35
- package/tui/screens/agents-screen-helpers.mjs +219 -0
- package/tui/screens/agents.mjs +287 -270
- package/tui/screens/status.mjs +51 -189
- package/tui/screens/tasks.mjs +41 -253
- package/ui/app.js +52 -23
- package/ui/components/chat-view.js +263 -84
- package/ui/components/diff-viewer.js +324 -140
- package/ui/components/kanban-board.js +13 -9
- package/ui/components/session-list.js +111 -41
- package/ui/demo-defaults.js +281 -17
- package/ui/demo.html +6 -0
- package/ui/modules/session-api.js +320 -5
- package/ui/modules/stream-timeline.js +356 -0
- package/ui/modules/telegram.js +5 -2
- package/ui/modules/worktree-recovery.js +85 -0
- package/ui/styles.css +44 -0
- package/ui/tabs/chat.js +19 -4
- package/ui/tabs/dashboard.js +22 -0
- package/ui/tabs/infra.js +25 -0
- package/ui/tabs/tasks.js +119 -11
- package/voice/voice-auth-manager.mjs +10 -5
- package/workflow/workflow-engine.mjs +96 -0
- package/workflow/workflow-nodes.mjs +747 -16
- package/workflow/workflow-templates.mjs +4 -0
- package/workflow-templates/github.mjs +2 -1
- package/workflow-templates/planning.mjs +2 -1
- package/workflow-templates/task-batch.mjs +9 -8
- package/workflow-templates/task-lifecycle.mjs +51 -3
- package/workspace/shared-knowledge.mjs +409 -155
package/.env.example
CHANGED
|
@@ -1039,6 +1039,18 @@ COPILOT_CLOUD_DISABLED=true
|
|
|
1039
1039
|
# WORKFLOW_AUTOMATION_ENABLED=true
|
|
1040
1040
|
# Optional dedup window to avoid event storms (milliseconds).
|
|
1041
1041
|
# WORKFLOW_EVENT_DEDUP_WINDOW_MS=15000
|
|
1042
|
+
# Monitor self-healing retry policy for startup unstick operations
|
|
1043
|
+
# (stale dispatch polling and workflow-history resume).
|
|
1044
|
+
# Max bounded retry attempts before terminal escalation.
|
|
1045
|
+
# WORKFLOW_RECOVERY_MAX_ATTEMPTS=5
|
|
1046
|
+
# Escalation warning threshold for repeated failures (must be <= max attempts).
|
|
1047
|
+
# WORKFLOW_RECOVERY_ESCALATION_THRESHOLD=3
|
|
1048
|
+
# Exponential backoff base delay in ms.
|
|
1049
|
+
# WORKFLOW_RECOVERY_BACKOFF_BASE_MS=5000
|
|
1050
|
+
# Exponential backoff maximum delay in ms.
|
|
1051
|
+
# WORKFLOW_RECOVERY_BACKOFF_MAX_MS=60000
|
|
1052
|
+
# Random jitter ratio (0.0-0.9) applied to backoff to prevent retry storms.
|
|
1053
|
+
# WORKFLOW_RECOVERY_BACKOFF_JITTER_RATIO=0.2
|
|
1042
1054
|
|
|
1043
1055
|
# ─── GitHub Issue Reconciler ─────────────────────────────────────────────────
|
|
1044
1056
|
# Periodically reconciles open GitHub issues against open/merged PRs.
|
package/README.md
CHANGED
|
@@ -144,6 +144,8 @@ Set `primaryAgent` in `.bosun/bosun.config.json` or choose an executor preset du
|
|
|
144
144
|
- `bosun --daemon --sentinel` starts daemon + sentinel together (recommended for unattended operation).
|
|
145
145
|
- `bosun --terminate` is the clean reset command when you suspect stale/ghost processes.
|
|
146
146
|
|
|
147
|
+
Telegram operators can pull the weekly agent work summary with `/weekly [days]` or `/report weekly [days]`. To post it automatically once per week, set `TELEGRAM_WEEKLY_REPORT_ENABLED=true` together with `TELEGRAM_WEEKLY_REPORT_DAY`, `TELEGRAM_WEEKLY_REPORT_HOUR`, and optional `TELEGRAM_WEEKLY_REPORT_DAYS`.
|
|
148
|
+
|
|
147
149
|
## Documentation
|
|
148
150
|
|
|
149
151
|
**Published docs (website):** https://bosun.engineer/docs/
|
package/agent/agent-pool.mjs
CHANGED
|
@@ -2871,6 +2871,19 @@ function sdkSupportsPersistentThreads(sdkName) {
|
|
|
2871
2871
|
|
|
2872
2872
|
/** @type {Map<string, ActiveSession>} */
|
|
2873
2873
|
const activeSessions = new Map();
|
|
2874
|
+
const ACTIVE_SESSION_LISTENERS = new Set();
|
|
2875
|
+
|
|
2876
|
+
function notifyActiveSessionListeners(reason = "update", taskKey = null) {
|
|
2877
|
+
if (ACTIVE_SESSION_LISTENERS.size === 0) return;
|
|
2878
|
+
const snapshot = getActiveSessions();
|
|
2879
|
+
for (const listener of ACTIVE_SESSION_LISTENERS) {
|
|
2880
|
+
try {
|
|
2881
|
+
listener(snapshot, { reason, taskKey });
|
|
2882
|
+
} catch {
|
|
2883
|
+
/* best effort */
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2874
2887
|
|
|
2875
2888
|
/** Threshold for approaching-exhaustion warning (80% of MAX_THREAD_TURNS). */
|
|
2876
2889
|
const THREAD_EXHAUSTION_WARNING_THRESHOLD = Math.floor(MAX_THREAD_TURNS * 0.8);
|
|
@@ -2892,6 +2905,7 @@ function registerActiveSession(taskKey, sdk, threadId, sendFn) {
|
|
|
2892
2905
|
send: sendFn,
|
|
2893
2906
|
registeredAt: Date.now(),
|
|
2894
2907
|
});
|
|
2908
|
+
notifyActiveSessionListeners("start", taskKey);
|
|
2895
2909
|
}
|
|
2896
2910
|
|
|
2897
2911
|
/**
|
|
@@ -2900,6 +2914,7 @@ function registerActiveSession(taskKey, sdk, threadId, sendFn) {
|
|
|
2900
2914
|
*/
|
|
2901
2915
|
function unregisterActiveSession(taskKey) {
|
|
2902
2916
|
activeSessions.delete(taskKey);
|
|
2917
|
+
notifyActiveSessionListeners("end", taskKey);
|
|
2903
2918
|
}
|
|
2904
2919
|
|
|
2905
2920
|
/**
|
|
@@ -2949,15 +2964,31 @@ export function hasActiveSession(taskKey) {
|
|
|
2949
2964
|
* Get info about which tasks have active (steerable) sessions.
|
|
2950
2965
|
* @returns {Array<{ taskKey: string, sdk: string, threadId: string|null, age: number }>}
|
|
2951
2966
|
*/
|
|
2967
|
+
export function addActiveSessionListener(listener) {
|
|
2968
|
+
if (typeof listener !== "function") return () => {};
|
|
2969
|
+
ACTIVE_SESSION_LISTENERS.add(listener);
|
|
2970
|
+
return () => ACTIVE_SESSION_LISTENERS.delete(listener);
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2952
2973
|
export function getActiveSessions() {
|
|
2953
2974
|
const now = Date.now();
|
|
2954
2975
|
const result = [];
|
|
2955
2976
|
for (const [key, session] of activeSessions) {
|
|
2977
|
+
const record = threadRegistry.get(key) || null;
|
|
2978
|
+
const createdAt = Number(record?.createdAt || session.registeredAt || now);
|
|
2979
|
+
const lastUsedAt = Number(record?.lastUsedAt || now);
|
|
2956
2980
|
result.push({
|
|
2981
|
+
id: key,
|
|
2982
|
+
taskId: key,
|
|
2957
2983
|
taskKey: key,
|
|
2958
2984
|
sdk: session.sdk,
|
|
2959
2985
|
threadId: session.threadId,
|
|
2960
|
-
|
|
2986
|
+
type: "task",
|
|
2987
|
+
status: "active",
|
|
2988
|
+
turnCount: Number(record?.turnCount || 0),
|
|
2989
|
+
createdAt: new Date(createdAt).toISOString(),
|
|
2990
|
+
lastActiveAt: new Date(lastUsedAt).toISOString(),
|
|
2991
|
+
age: Math.max(0, now - createdAt),
|
|
2961
2992
|
});
|
|
2962
2993
|
}
|
|
2963
2994
|
return result;
|
|
@@ -3973,3 +4004,5 @@ export function getActiveThreads() {
|
|
|
3973
4004
|
}
|
|
3974
4005
|
return result;
|
|
3975
4006
|
}
|
|
4007
|
+
|
|
4008
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { resolveRepoRoot } from "../config/repo-root.mjs";
|
|
4
5
|
|
|
5
6
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -89,6 +90,12 @@ function defaultLogPaths() {
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
export function getWeeklyReportStatePath(options = {}) {
|
|
94
|
+
if (options?.statePath) return resolve(String(options.statePath));
|
|
95
|
+
const repoRoot = resolveRepoRoot();
|
|
96
|
+
return resolve(repoRoot, ".cache", "agent-work-logs", "weekly-report-state.json");
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
async function defaultLoadMetrics() {
|
|
93
100
|
const { metricsPath } = defaultLogPaths();
|
|
94
101
|
return await readJsonlFile(metricsPath);
|
|
@@ -99,6 +106,44 @@ async function defaultLoadErrors() {
|
|
|
99
106
|
return await readJsonlFile(errorsPath);
|
|
100
107
|
}
|
|
101
108
|
|
|
109
|
+
export async function readWeeklyReportScheduleState(options = {}) {
|
|
110
|
+
const statePath = getWeeklyReportStatePath(options);
|
|
111
|
+
try {
|
|
112
|
+
const raw = await readFile(statePath, "utf8");
|
|
113
|
+
const parsed = JSON.parse(raw);
|
|
114
|
+
const lastSentAt = String(parsed?.lastSentAt || "").trim();
|
|
115
|
+
if (!lastSentAt) {
|
|
116
|
+
return { statePath, lastSentAt: null };
|
|
117
|
+
}
|
|
118
|
+
const lastSentMs = Date.parse(lastSentAt);
|
|
119
|
+
return {
|
|
120
|
+
statePath,
|
|
121
|
+
lastSentAt: Number.isFinite(lastSentMs) ? new Date(lastSentMs).toISOString() : null,
|
|
122
|
+
};
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err?.code === "ENOENT") {
|
|
125
|
+
return { statePath, lastSentAt: null };
|
|
126
|
+
}
|
|
127
|
+
return { statePath, lastSentAt: null };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function writeWeeklyReportScheduleState(lastSentAt, options = {}) {
|
|
132
|
+
const statePath = getWeeklyReportStatePath(options);
|
|
133
|
+
const normalized = String(lastSentAt || "").trim();
|
|
134
|
+
const lastSentMs = Date.parse(normalized);
|
|
135
|
+
const payload = {
|
|
136
|
+
lastSentAt: Number.isFinite(lastSentMs) ? new Date(lastSentMs).toISOString() : null,
|
|
137
|
+
updatedAt: new Date().toISOString(),
|
|
138
|
+
};
|
|
139
|
+
await mkdir(dirname(statePath), { recursive: true });
|
|
140
|
+
await writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
141
|
+
return {
|
|
142
|
+
statePath,
|
|
143
|
+
lastSentAt: payload.lastSentAt,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
102
147
|
export function buildWeeklyAgentWorkSummary(options = {}) {
|
|
103
148
|
const now = toSafeDate(options.now);
|
|
104
149
|
const days = toPositiveDays(options.days, DEFAULT_DAYS);
|
|
@@ -359,4 +404,45 @@ export function shouldSendWeeklyReport(options = {}) {
|
|
|
359
404
|
return true;
|
|
360
405
|
}
|
|
361
406
|
return lastSentMs < scheduledThisWeek.getTime();
|
|
362
|
-
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function runWeeklyAgentWorkReportCli(options = {}) {
|
|
410
|
+
const argv = Array.isArray(options.argv) ? options.argv : process.argv.slice(2);
|
|
411
|
+
const stdout = options.stdout || process.stdout;
|
|
412
|
+
const stderr = options.stderr || process.stderr;
|
|
413
|
+
const rawDays = String(argv[0] || "").trim();
|
|
414
|
+
const parsedDays = Number.parseInt(rawDays, 10);
|
|
415
|
+
const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : undefined;
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const report = await generateWeeklyAgentWorkReport({
|
|
419
|
+
...(days ? { days } : {}),
|
|
420
|
+
...(options.now ? { now: options.now } : {}),
|
|
421
|
+
...(typeof options.loadMetrics === "function"
|
|
422
|
+
? { loadMetrics: options.loadMetrics }
|
|
423
|
+
: {}),
|
|
424
|
+
...(typeof options.loadErrors === "function"
|
|
425
|
+
? { loadErrors: options.loadErrors }
|
|
426
|
+
: {}),
|
|
427
|
+
});
|
|
428
|
+
stdout.write(`${report.text}\n`);
|
|
429
|
+
if (Array.isArray(report.warnings) && report.warnings.length > 0) {
|
|
430
|
+
stderr.write(`Warnings: ${report.warnings.join(" | ")}\n`);
|
|
431
|
+
}
|
|
432
|
+
return 0;
|
|
433
|
+
} catch (err) {
|
|
434
|
+
stderr.write(`Failed to generate weekly agent work report: ${err?.message || err}\n`);
|
|
435
|
+
return 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const isDirectRun =
|
|
440
|
+
process.argv[1] &&
|
|
441
|
+
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
442
|
+
|
|
443
|
+
if (isDirectRun) {
|
|
444
|
+
const exitCode = await runWeeklyAgentWorkReportCli();
|
|
445
|
+
if (exitCode !== 0) {
|
|
446
|
+
process.exitCode = exitCode;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -203,8 +203,10 @@ function buildErrorCorrelationEntries(errors, taskProfiles) {
|
|
|
203
203
|
count: 0,
|
|
204
204
|
task_ids: new Set(),
|
|
205
205
|
by_executor: {},
|
|
206
|
+
by_model: {},
|
|
206
207
|
by_size: {},
|
|
207
208
|
by_complexity: {},
|
|
209
|
+
avg_task_duration_ms: 0,
|
|
208
210
|
sample_message: "",
|
|
209
211
|
first_seen_ts: null,
|
|
210
212
|
last_seen_ts: null,
|
|
@@ -240,13 +242,23 @@ function buildErrorCorrelationEntries(errors, taskProfiles) {
|
|
|
240
242
|
extractSizeLabelFromTitle(error.task_title),
|
|
241
243
|
);
|
|
242
244
|
const executor = resolveKnownValue(profile?.executor, error.executor);
|
|
245
|
+
const model = resolveKnownValue(profile?.model, error.model);
|
|
243
246
|
const complexity = resolveKnownValue(profile?.complexity);
|
|
244
247
|
|
|
245
248
|
incrementCounter(entry.by_size, sizeLabel);
|
|
246
249
|
incrementCounter(entry.by_executor, executor);
|
|
250
|
+
incrementCounter(entry.by_model, model);
|
|
247
251
|
incrementCounter(entry.by_complexity, complexity);
|
|
248
252
|
}
|
|
249
253
|
|
|
254
|
+
for (const entry of correlations.values()) {
|
|
255
|
+
const durations = [...entry.task_ids]
|
|
256
|
+
.map((taskId) => taskProfiles.get(taskId)?.avg_duration_ms || 0)
|
|
257
|
+
.filter((duration) => duration > 0);
|
|
258
|
+
entry.avg_task_duration_ms =
|
|
259
|
+
durations.length > 0 ? average(durations) : 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
250
262
|
return correlations;
|
|
251
263
|
}
|
|
252
264
|
|
|
@@ -300,7 +312,9 @@ export function buildErrorCorrelationJsonPayload(summary, { now } = {}) {
|
|
|
300
312
|
last_seen: entry.last_seen_ts
|
|
301
313
|
? new Date(entry.last_seen_ts).toISOString()
|
|
302
314
|
: null,
|
|
315
|
+
avg_task_duration_ms: entry.avg_task_duration_ms || 0,
|
|
303
316
|
by_executor: buildDistribution(entry.by_executor, entry.count),
|
|
317
|
+
by_model: buildDistribution(entry.by_model, entry.count),
|
|
304
318
|
by_size: buildDistribution(entry.by_size, entry.count),
|
|
305
319
|
by_complexity: buildDistribution(entry.by_complexity, entry.count),
|
|
306
320
|
})),
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { readFile, readdir } from "fs/promises";
|
|
22
22
|
import { createReadStream, existsSync } from "fs";
|
|
23
23
|
import { createInterface } from "readline";
|
|
24
|
-
import { resolve, dirname } from "path";
|
|
24
|
+
import { resolve, dirname, isAbsolute } from "path";
|
|
25
25
|
import { fileURLToPath } from "url";
|
|
26
26
|
import {
|
|
27
27
|
buildErrorClusters,
|
|
@@ -44,8 +44,16 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
44
44
|
const __dirname = dirname(__filename);
|
|
45
45
|
const repoRoot = resolve(__dirname, "../..");
|
|
46
46
|
|
|
47
|
+
function resolveAgentWorkLogDir() {
|
|
48
|
+
const configured = String(process.env.AGENT_WORK_LOG_DIR || "").trim();
|
|
49
|
+
if (!configured) {
|
|
50
|
+
return resolve(repoRoot, ".cache/agent-work-logs");
|
|
51
|
+
}
|
|
52
|
+
return isAbsolute(configured) ? configured : resolve(repoRoot, configured);
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
// ── Log Paths ───────────────────────────────────────────────────────────────
|
|
48
|
-
const LOG_DIR =
|
|
56
|
+
const LOG_DIR = resolveAgentWorkLogDir();
|
|
49
57
|
const STREAM_LOG = resolve(LOG_DIR, "agent-work-stream.jsonl");
|
|
50
58
|
const ERRORS_LOG = resolve(LOG_DIR, "agent-errors.jsonl");
|
|
51
59
|
const METRICS_LOG = resolve(LOG_DIR, "agent-metrics.jsonl");
|
|
@@ -339,6 +347,14 @@ function formatDistribution(counts, total, limit = 5) {
|
|
|
339
347
|
.join(", ");
|
|
340
348
|
}
|
|
341
349
|
|
|
350
|
+
function formatDurationMs(durationMs) {
|
|
351
|
+
const value = Number(durationMs) || 0;
|
|
352
|
+
if (value <= 0) return "unknown";
|
|
353
|
+
if (value < 1000) return `${value.toFixed(0)}ms`;
|
|
354
|
+
if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
|
|
355
|
+
return `${(value / 60_000).toFixed(1)}m`;
|
|
356
|
+
}
|
|
357
|
+
|
|
342
358
|
function topN(obj, n) {
|
|
343
359
|
return Object.entries(obj)
|
|
344
360
|
.sort((a, b) => b[1] - a[1])
|
|
@@ -478,7 +494,7 @@ async function correlateErrors(options) {
|
|
|
478
494
|
const errors = await loadErrors({ days: windowDays });
|
|
479
495
|
|
|
480
496
|
if (errors.length === 0) {
|
|
481
|
-
const message = "No
|
|
497
|
+
const message = "No data found for selected window";
|
|
482
498
|
if (useJson) {
|
|
483
499
|
console.log(
|
|
484
500
|
JSON.stringify(
|
|
@@ -522,10 +538,14 @@ async function correlateErrors(options) {
|
|
|
522
538
|
console.log(
|
|
523
539
|
` Executors: ${formatDistribution(entry.by_executor, entry.count)}`,
|
|
524
540
|
);
|
|
541
|
+
console.log(` Models: ${formatDistribution(entry.by_model, entry.count)}`);
|
|
525
542
|
console.log(` Sizes: ${formatDistribution(entry.by_size, entry.count)}`);
|
|
526
543
|
console.log(
|
|
527
544
|
` Complexity: ${formatDistribution(entry.by_complexity, entry.count)}`,
|
|
528
545
|
);
|
|
546
|
+
console.log(
|
|
547
|
+
` Avg task duration: ${formatDurationMs(entry.avg_task_duration_ms)}`,
|
|
548
|
+
);
|
|
529
549
|
if (entry.sample_message) {
|
|
530
550
|
console.log(
|
|
531
551
|
` Sample: ${entry.sample_message.slice(0, 100)}${entry.sample_message.length > 100 ? "..." : ""}`,
|
package/agent/primary-agent.mjs
CHANGED
|
@@ -191,6 +191,134 @@ function appendAttachmentsToPrompt(message, attachments) {
|
|
|
191
191
|
return { message: `${message}${lines.join("\n")}`, appended: true };
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
function normalizeRepoMap(repoMap) {
|
|
195
|
+
if (!repoMap || typeof repoMap !== "object") return null;
|
|
196
|
+
const root = String(repoMap.root || repoMap.repoRoot || "").trim();
|
|
197
|
+
const files = Array.isArray(repoMap.files)
|
|
198
|
+
? repoMap.files
|
|
199
|
+
.filter((entry) => entry && typeof entry === "object")
|
|
200
|
+
.map((entry) => ({
|
|
201
|
+
path: String(entry.path || entry.file || "").trim(),
|
|
202
|
+
summary: String(entry.summary || entry.description || "").trim(),
|
|
203
|
+
symbols: Array.isArray(entry.symbols)
|
|
204
|
+
? entry.symbols.map((symbol) => String(symbol || "").trim()).filter(Boolean)
|
|
205
|
+
: [],
|
|
206
|
+
}))
|
|
207
|
+
.filter((entry) => entry.path)
|
|
208
|
+
: [];
|
|
209
|
+
if (!root && files.length === 0) return null;
|
|
210
|
+
return { root, files };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function formatRepoMap(repoMap) {
|
|
214
|
+
const normalized = normalizeRepoMap(repoMap);
|
|
215
|
+
if (!normalized) return "";
|
|
216
|
+
const lines = ["## Repo Map"];
|
|
217
|
+
if (normalized.root) lines.push(`- Root: ${normalized.root}`);
|
|
218
|
+
for (const file of normalized.files) {
|
|
219
|
+
const parts = [file.path];
|
|
220
|
+
if (file.symbols.length) parts.push(`symbols: ${file.symbols.join(", ")}`);
|
|
221
|
+
if (file.summary) parts.push(file.summary);
|
|
222
|
+
lines.push(`- ${parts.join(" — ")}`);
|
|
223
|
+
}
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function summarizePathSegment(segment) {
|
|
228
|
+
return String(segment || "")
|
|
229
|
+
.replace(/[-_]+/g, " ")
|
|
230
|
+
.replace(/\.m?js$/i, "")
|
|
231
|
+
.replace(/\s+/g, " ")
|
|
232
|
+
.trim();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function inferRepoMapEntry(pathValue) {
|
|
236
|
+
const path = String(pathValue || "").trim().replace(/\\/g, "/");
|
|
237
|
+
if (!path) return null;
|
|
238
|
+
const name = path.split("/").pop() || path;
|
|
239
|
+
const stem = summarizePathSegment(name);
|
|
240
|
+
const dir = path.includes("/") ? path.split("/").slice(0, -1).join("/") : "";
|
|
241
|
+
const dirHint = dir ? summarizePathSegment(dir.split("/").pop()) : "";
|
|
242
|
+
const symbols = [];
|
|
243
|
+
const lowerStem = stem.toLowerCase();
|
|
244
|
+
if (lowerStem) {
|
|
245
|
+
const compact = lowerStem
|
|
246
|
+
.split(" ")
|
|
247
|
+
.filter(Boolean)
|
|
248
|
+
.map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
|
|
249
|
+
.join("");
|
|
250
|
+
if (compact) {
|
|
251
|
+
symbols.push(compact);
|
|
252
|
+
if (!compact.startsWith("test")) symbols.push(`test${compact.charAt(0).toUpperCase()}${compact.slice(1)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const summaryParts = [];
|
|
256
|
+
if (dirHint) summaryParts.push(`${dirHint} module`);
|
|
257
|
+
if (stem) summaryParts.push(stem);
|
|
258
|
+
return {
|
|
259
|
+
path,
|
|
260
|
+
summary: summaryParts.join(" — "),
|
|
261
|
+
symbols: [...new Set(symbols)].slice(0, 3),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function deriveRepoMap(options = {}) {
|
|
266
|
+
const explicit = normalizeRepoMap(options.repoMap);
|
|
267
|
+
if (explicit) return explicit;
|
|
268
|
+
const changedFiles = Array.isArray(options.changedFiles)
|
|
269
|
+
? options.changedFiles.map((value) => String(value || "").trim()).filter(Boolean)
|
|
270
|
+
: [];
|
|
271
|
+
if (!changedFiles.length) return null;
|
|
272
|
+
const root = String(options.repoRoot || options.cwd || resolveRepoRoot() || "").trim();
|
|
273
|
+
const files = changedFiles
|
|
274
|
+
.map((pathValue) => inferRepoMapEntry(pathValue))
|
|
275
|
+
.filter(Boolean)
|
|
276
|
+
.slice(0, Number(options.repoMapFileLimit) > 0 ? Number(options.repoMapFileLimit) : 12);
|
|
277
|
+
if (!root && files.length === 0) return null;
|
|
278
|
+
return { root, files };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function inferExecutionRole(options = {}, effectiveMode = "agent") {
|
|
282
|
+
const explicitRole = String(options.executionRole || "").trim().toLowerCase();
|
|
283
|
+
if (explicitRole) return explicitRole;
|
|
284
|
+
if (effectiveMode === "plan") return "architect";
|
|
285
|
+
const architectPlan = String(options.architectPlan || options.planSummary || "").trim();
|
|
286
|
+
if (architectPlan) return "editor";
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
function buildArchitectEditorFrame(options = {}, effectiveMode = "agent") {
|
|
290
|
+
const executionRole = inferExecutionRole(options, effectiveMode);
|
|
291
|
+
const repoMapBlock = formatRepoMap(deriveRepoMap(options));
|
|
292
|
+
const architectPlan = String(options.architectPlan || options.planSummary || "").trim();
|
|
293
|
+
const lines = ["## Architect/Editor Execution"];
|
|
294
|
+
|
|
295
|
+
if (executionRole === "architect") {
|
|
296
|
+
lines.push(
|
|
297
|
+
"You are the architect phase.",
|
|
298
|
+
"Do not implement code changes in this phase.",
|
|
299
|
+
"Use the repo map to produce a compact structural plan that an editor can execute and validate.",
|
|
300
|
+
"Editor handoff: include ordered implementation steps, touched files, risks, and validation guidance.",
|
|
301
|
+
);
|
|
302
|
+
} else if (executionRole === "editor") {
|
|
303
|
+
lines.push(
|
|
304
|
+
"You are the editor phase.",
|
|
305
|
+
"Implement the approved plan with focused edits and verification.",
|
|
306
|
+
"Prefer the supplied repo map over broad rediscovery unless validation reveals drift.",
|
|
307
|
+
);
|
|
308
|
+
if (architectPlan) {
|
|
309
|
+
lines.push("", "## Architect Plan", architectPlan);
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
return repoMapBlock;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (repoMapBlock) {
|
|
316
|
+
lines.push("", repoMapBlock);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return lines.join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
194
322
|
function summarizeContextCompressionItems(items) {
|
|
195
323
|
if (!Array.isArray(items) || items.length === 0) return null;
|
|
196
324
|
|
|
@@ -1257,6 +1385,18 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
|
|
|
1257
1385
|
timestamp: new Date().toISOString(),
|
|
1258
1386
|
_sessionType: sessionType,
|
|
1259
1387
|
});
|
|
1388
|
+
const compressionSummary = summarizeContextCompressionItems(retryResult?.items);
|
|
1389
|
+
if (compressionSummary) {
|
|
1390
|
+
tracker.recordEvent(sessionId, {
|
|
1391
|
+
role: "system",
|
|
1392
|
+
type: "system",
|
|
1393
|
+
content: compressionSummary.content,
|
|
1394
|
+
timestamp: new Date().toISOString(),
|
|
1395
|
+
meta: {
|
|
1396
|
+
contextCompression: compressionSummary,
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1260
1400
|
clearAdapterFailureState(adapterName);
|
|
1261
1401
|
return retryResult;
|
|
1262
1402
|
} catch (retryErr) {
|
|
@@ -1538,3 +1678,5 @@ export async function execSdkCommand(command, args = "", adapterName, options =
|
|
|
1538
1678
|
}
|
|
1539
1679
|
return adapter.execSdkCommand(cmd, args, options);
|
|
1540
1680
|
}
|
|
1681
|
+
|
|
1682
|
+
|
package/bosun-tui.mjs
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import { resolve, dirname } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { readFileSync
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
18
|
|
|
19
|
-
const
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = dirname(__filename);
|
|
20
21
|
|
|
21
22
|
function showHelp() {
|
|
22
23
|
const version = JSON.parse(
|
|
@@ -50,7 +51,7 @@ function showHelp() {
|
|
|
50
51
|
Enter Select / Execute action
|
|
51
52
|
Esc Back / Close modal
|
|
52
53
|
c Create new task (tasks screen)
|
|
53
|
-
r
|
|
54
|
+
r Resume selected agent session (Agents screen)
|
|
54
55
|
q Quit
|
|
55
56
|
|
|
56
57
|
EXAMPLES
|
package/bosun.schema.json
CHANGED
package/config/config.mjs
CHANGED
|
@@ -454,6 +454,24 @@ function parseEnvBoolean(value, defaultValue) {
|
|
|
454
454
|
return defaultValue;
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
function parseBoundedInteger(value, defaultValue, { min = null, max = null } = {}) {
|
|
458
|
+
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
|
|
459
|
+
if (!Number.isFinite(parsed)) return defaultValue;
|
|
460
|
+
if (Number.isFinite(min) && parsed < min) return defaultValue;
|
|
461
|
+
if (Number.isFinite(max) && parsed > max) return defaultValue;
|
|
462
|
+
return parsed;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function parseBoundedNumber(value, defaultValue, { min = null, max = null } = {}) {
|
|
466
|
+
const normalized = String(value ?? "").trim();
|
|
467
|
+
if (!normalized) return defaultValue;
|
|
468
|
+
const parsed = Number(normalized);
|
|
469
|
+
if (!Number.isFinite(parsed)) return defaultValue;
|
|
470
|
+
if (Number.isFinite(min) && parsed < min) return defaultValue;
|
|
471
|
+
if (Number.isFinite(max) && parsed > max) return defaultValue;
|
|
472
|
+
return parsed;
|
|
473
|
+
}
|
|
474
|
+
|
|
457
475
|
function isEnvEnabled(value, defaultValue = false) {
|
|
458
476
|
return parseEnvBoolean(value, defaultValue);
|
|
459
477
|
}
|
|
@@ -1578,6 +1596,10 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
1578
1596
|
validateKanbanBackendConfig({ kanbanBackend, kanban, jira });
|
|
1579
1597
|
|
|
1580
1598
|
const internalExecutorConfig = configData.internalExecutor || {};
|
|
1599
|
+
const workflowRecoveryConfig =
|
|
1600
|
+
configData.workflowRecovery && typeof configData.workflowRecovery === "object"
|
|
1601
|
+
? configData.workflowRecovery
|
|
1602
|
+
: {};
|
|
1581
1603
|
const envInternalExecutorParallel = configFileHadInvalidJson
|
|
1582
1604
|
? undefined
|
|
1583
1605
|
: process.env.INTERNAL_EXECUTOR_PARALLEL;
|
|
@@ -1629,6 +1651,41 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
1629
1651
|
String(reviewAgentToggleRaw).trim() !== ""
|
|
1630
1652
|
? isEnvEnabled(reviewAgentToggleRaw, true)
|
|
1631
1653
|
: internalExecutorConfig.reviewAgentEnabled !== false;
|
|
1654
|
+
const workflowRecoveryMaxAttempts = parseBoundedInteger(
|
|
1655
|
+
process.env.WORKFLOW_RECOVERY_MAX_ATTEMPTS ??
|
|
1656
|
+
workflowRecoveryConfig.maxAttempts,
|
|
1657
|
+
5,
|
|
1658
|
+
{ min: 1, max: 20 },
|
|
1659
|
+
);
|
|
1660
|
+
const workflowRecoveryEscalationThreshold = parseBoundedInteger(
|
|
1661
|
+
process.env.WORKFLOW_RECOVERY_ESCALATION_THRESHOLD ??
|
|
1662
|
+
workflowRecoveryConfig.escalationWarnAfterAttempts ??
|
|
1663
|
+
workflowRecoveryConfig.escalationThreshold,
|
|
1664
|
+
3,
|
|
1665
|
+
{ min: 1, max: workflowRecoveryMaxAttempts },
|
|
1666
|
+
);
|
|
1667
|
+
const workflowRecovery = Object.freeze({
|
|
1668
|
+
maxAttempts: workflowRecoveryMaxAttempts,
|
|
1669
|
+
escalationWarnAfterAttempts: workflowRecoveryEscalationThreshold,
|
|
1670
|
+
baseBackoffMs: parseBoundedInteger(
|
|
1671
|
+
process.env.WORKFLOW_RECOVERY_BACKOFF_BASE_MS ??
|
|
1672
|
+
workflowRecoveryConfig.baseBackoffMs,
|
|
1673
|
+
5000,
|
|
1674
|
+
{ min: 50, max: 60_000 },
|
|
1675
|
+
),
|
|
1676
|
+
maxBackoffMs: parseBoundedInteger(
|
|
1677
|
+
process.env.WORKFLOW_RECOVERY_BACKOFF_MAX_MS ??
|
|
1678
|
+
workflowRecoveryConfig.maxBackoffMs,
|
|
1679
|
+
60_000,
|
|
1680
|
+
{ min: 1000, max: 30 * 60 * 1000 },
|
|
1681
|
+
),
|
|
1682
|
+
jitterRatio: parseBoundedNumber(
|
|
1683
|
+
process.env.WORKFLOW_RECOVERY_BACKOFF_JITTER_RATIO ??
|
|
1684
|
+
workflowRecoveryConfig.jitterRatio,
|
|
1685
|
+
0.2,
|
|
1686
|
+
{ min: 0, max: 0.9 },
|
|
1687
|
+
),
|
|
1688
|
+
});
|
|
1632
1689
|
const internalExecutor = {
|
|
1633
1690
|
mode: ["vk", "internal", "hybrid"].includes(executorMode)
|
|
1634
1691
|
? executorMode
|
|
@@ -2059,6 +2116,7 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
2059
2116
|
|
|
2060
2117
|
// Internal Executor
|
|
2061
2118
|
internalExecutor,
|
|
2119
|
+
workflowRecovery,
|
|
2062
2120
|
executorMode: internalExecutor.mode,
|
|
2063
2121
|
kanban,
|
|
2064
2122
|
kanbanSource,
|
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { execSync } from "node:child_process";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
|
|
6
|
+
export function normalizeWorkspaceHealthPath(value) {
|
|
7
|
+
const trimmed = String(value || "").trim();
|
|
8
|
+
if (!trimmed) return "";
|
|
9
|
+
const isWindowsDrivePath = /^[A-Za-z]:[\\/]/.test(trimmed);
|
|
10
|
+
const isUncPath = trimmed.startsWith("\\\\");
|
|
11
|
+
const normalized = (isWindowsDrivePath || isUncPath ? trimmed : resolve(trimmed))
|
|
12
|
+
.replace(/\\/g, "/");
|
|
13
|
+
if (/^[A-Z]:/.test(normalized)) {
|
|
14
|
+
return normalized[0].toLowerCase() + normalized.slice(1);
|
|
15
|
+
}
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isAbsoluteWorkspaceHealthPath(value) {
|
|
20
|
+
const trimmed = String(value || "").trim();
|
|
21
|
+
if (!trimmed) return false;
|
|
22
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed)) return true;
|
|
23
|
+
if (trimmed.startsWith("\\\\")) return true;
|
|
24
|
+
return isAbsolute(trimmed);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writableRootContainsPath(root, candidate) {
|
|
28
|
+
const normalizedRoot = normalizeWorkspaceHealthPath(root);
|
|
29
|
+
const normalizedCandidate = normalizeWorkspaceHealthPath(candidate);
|
|
30
|
+
if (!normalizedRoot || !normalizedCandidate) return false;
|
|
31
|
+
return (
|
|
32
|
+
normalizedCandidate === normalizedRoot ||
|
|
33
|
+
normalizedCandidate.startsWith(`${normalizedRoot}/`)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
export function runWorkspaceHealthCheck(options = {}) {
|
|
7
38
|
const configDir = options.configDir || process.env.BOSUN_DIR || join(homedir(), "bosun");
|
|
8
|
-
|
|
39
|
+
const issues = { errors: [], warnings: [], infos: [] };
|
|
9
40
|
const workspaceResults = [];
|
|
10
41
|
|
|
11
42
|
// 1. Check if workspaces are configured
|
|
@@ -128,7 +159,7 @@ export function runWorkspaceHealthCheck(options = {}) {
|
|
|
128
159
|
for (const repo of ws.repos || []) {
|
|
129
160
|
const repoPath = join(wsPath, repo.name || repo.slug || "");
|
|
130
161
|
const gitPath = join(repoPath, ".git");
|
|
131
|
-
if (existsSync(gitPath) && !roots.some(
|
|
162
|
+
if (existsSync(gitPath) && !roots.some((root) => writableRootContainsPath(root, gitPath))) {
|
|
132
163
|
issues.warnings.push({
|
|
133
164
|
code: "WS_SANDBOX_MISSING_ROOT",
|
|
134
165
|
message: `Workspace repo .git not in Codex writable_roots: ${gitPath}`,
|
|
@@ -140,13 +171,13 @@ export function runWorkspaceHealthCheck(options = {}) {
|
|
|
140
171
|
|
|
141
172
|
// Check for phantom/relative writable roots
|
|
142
173
|
for (const root of roots) {
|
|
143
|
-
if (!root
|
|
174
|
+
if (!isAbsoluteWorkspaceHealthPath(root)) {
|
|
144
175
|
issues.warnings.push({
|
|
145
176
|
code: "WS_SANDBOX_RELATIVE_ROOT",
|
|
146
177
|
message: `Relative path in Codex writable_roots: "${root}" — may resolve incorrectly`,
|
|
147
178
|
fix: `Remove "${root}" from writable_roots in ~/.codex/config.toml and run 'bosun --setup'`,
|
|
148
179
|
});
|
|
149
|
-
} else if (!existsSync(root) && root !== "/tmp") {
|
|
180
|
+
} else if (!existsSync(root) && normalizeWorkspaceHealthPath(root) !== normalizeWorkspaceHealthPath("/tmp")) {
|
|
150
181
|
issues.infos.push({
|
|
151
182
|
code: "WS_SANDBOX_PHANTOM_ROOT",
|
|
152
183
|
message: `Codex writable_root path does not exist: ${root}`,
|
|
@@ -239,4 +270,3 @@ export function formatWorkspaceHealthReport(result) {
|
|
|
239
270
|
|
|
240
271
|
return lines.join("\n");
|
|
241
272
|
}
|
|
242
|
-
|