night-orch 0.3.2 → 0.4.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/LICENSE +21 -0
- package/README.md +47 -108
- package/dist/cli/commands/doctor.d.ts +1 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +18 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/monitoring.d.ts.map +1 -1
- package/dist/cli/commands/monitoring.js +7 -3
- package/dist/cli/commands/monitoring.js.map +1 -1
- package/dist/cli/commands/settings.d.ts.map +1 -1
- package/dist/cli/commands/settings.js +40 -4
- package/dist/cli/commands/settings.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +26 -3
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/index.js +3 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/tui/app.d.ts.map +1 -1
- package/dist/cli/tui/app.js +52 -3
- package/dist/cli/tui/app.js.map +1 -1
- package/dist/cli/tui/header.d.ts.map +1 -1
- package/dist/cli/tui/header.js +2 -1
- package/dist/cli/tui/header.js.map +1 -1
- package/dist/cli/tui/settings-view.d.ts.map +1 -1
- package/dist/cli/tui/settings-view.js +22 -5
- package/dist/cli/tui/settings-view.js.map +1 -1
- package/dist/cli/tui/stats-view.js +2 -2
- package/dist/cli/tui/stats-view.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +141 -13
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +903 -21
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +77 -6
- package/dist/config/schema.js.map +1 -1
- package/dist/forge/types.d.ts +7 -0
- package/dist/forge/types.d.ts.map +1 -1
- package/dist/git/repo.d.ts +8 -0
- package/dist/git/repo.d.ts.map +1 -1
- package/dist/git/repo.js +19 -0
- package/dist/git/repo.js.map +1 -1
- package/dist/git/worktree.d.ts +3 -0
- package/dist/git/worktree.d.ts.map +1 -1
- package/dist/git/worktree.js +43 -21
- package/dist/git/worktree.js.map +1 -1
- package/dist/loop/cost.d.ts +33 -8
- package/dist/loop/cost.d.ts.map +1 -1
- package/dist/loop/cost.js +250 -46
- package/dist/loop/cost.js.map +1 -1
- package/dist/loop/decision.js +3 -3
- package/dist/loop/decision.js.map +1 -1
- package/dist/loop/engine.d.ts +1 -0
- package/dist/loop/engine.d.ts.map +1 -1
- package/dist/loop/engine.js +76 -13
- package/dist/loop/engine.js.map +1 -1
- package/dist/loop/parallel.d.ts.map +1 -1
- package/dist/loop/parallel.js +2 -0
- package/dist/loop/parallel.js.map +1 -1
- package/dist/loop/pricing.d.ts +11 -4
- package/dist/loop/pricing.d.ts.map +1 -1
- package/dist/loop/pricing.js +62 -20
- package/dist/loop/pricing.js.map +1 -1
- package/dist/loop/progress.d.ts +24 -0
- package/dist/loop/progress.d.ts.map +1 -0
- package/dist/loop/progress.js +52 -0
- package/dist/loop/progress.js.map +1 -0
- package/dist/loop/step-executor.d.ts +4 -5
- package/dist/loop/step-executor.d.ts.map +1 -1
- package/dist/loop/step-executor.js +1 -0
- package/dist/loop/step-executor.js.map +1 -1
- package/dist/loop/types.d.ts +17 -0
- package/dist/loop/types.d.ts.map +1 -1
- package/dist/mcp/tools/admin.d.ts +29 -0
- package/dist/mcp/tools/admin.d.ts.map +1 -0
- package/dist/mcp/tools/admin.js +89 -0
- package/dist/mcp/tools/admin.js.map +1 -0
- package/dist/mcp/tools/auth.d.ts +3 -0
- package/dist/mcp/tools/auth.d.ts.map +1 -0
- package/dist/mcp/tools/auth.js +19 -0
- package/dist/mcp/tools/auth.js.map +1 -0
- package/dist/mcp/tools/index.d.ts.map +1 -1
- package/dist/mcp/tools/index.js +5 -533
- package/dist/mcp/tools/index.js.map +1 -1
- package/dist/mcp/tools/operations.d.ts +32 -0
- package/dist/mcp/tools/operations.d.ts.map +1 -0
- package/dist/mcp/tools/operations.js +95 -0
- package/dist/mcp/tools/operations.js.map +1 -0
- package/dist/mcp/tools/settings.d.ts +12 -0
- package/dist/mcp/tools/settings.d.ts.map +1 -0
- package/dist/mcp/tools/settings.js +58 -0
- package/dist/mcp/tools/settings.js.map +1 -0
- package/dist/mcp/tools/status.d.ts +25 -0
- package/dist/mcp/tools/status.d.ts.map +1 -0
- package/dist/mcp/tools/status.js +307 -0
- package/dist/mcp/tools/status.js.map +1 -0
- package/dist/ops/continue.js +6 -1
- package/dist/ops/continue.js.map +1 -1
- package/dist/ops/project-check.d.ts +15 -0
- package/dist/ops/project-check.d.ts.map +1 -0
- package/dist/ops/project-check.js +136 -0
- package/dist/ops/project-check.js.map +1 -0
- package/dist/ops/rebase-and-check.d.ts +2 -1
- package/dist/ops/rebase-and-check.d.ts.map +1 -1
- package/dist/ops/rebase-and-check.js +2 -2
- package/dist/ops/rebase-and-check.js.map +1 -1
- package/dist/ops/rebase.d.ts +8 -5
- package/dist/ops/rebase.d.ts.map +1 -1
- package/dist/ops/rebase.js +43 -29
- package/dist/ops/rebase.js.map +1 -1
- package/dist/runner/comment-commands.d.ts +20 -0
- package/dist/runner/comment-commands.d.ts.map +1 -0
- package/dist/runner/comment-commands.js +221 -0
- package/dist/runner/comment-commands.js.map +1 -0
- package/dist/runner/helpers.d.ts +57 -0
- package/dist/runner/helpers.d.ts.map +1 -0
- package/dist/runner/helpers.js +259 -0
- package/dist/runner/helpers.js.map +1 -0
- package/dist/runner/poller.d.ts.map +1 -1
- package/dist/runner/poller.js +19 -781
- package/dist/runner/poller.js.map +1 -1
- package/dist/runner/reaction-scan.d.ts +17 -0
- package/dist/runner/reaction-scan.d.ts.map +1 -0
- package/dist/runner/reaction-scan.js +33 -0
- package/dist/runner/reaction-scan.js.map +1 -0
- package/dist/runner/run-finalizer.d.ts +30 -0
- package/dist/runner/run-finalizer.d.ts.map +1 -0
- package/dist/runner/run-finalizer.js +217 -0
- package/dist/runner/run-finalizer.js.map +1 -0
- package/dist/settings/definitions/github.d.ts +3 -0
- package/dist/settings/definitions/github.d.ts.map +1 -0
- package/dist/settings/definitions/github.js +267 -0
- package/dist/settings/definitions/github.js.map +1 -0
- package/dist/settings/definitions/loop.d.ts +3 -0
- package/dist/settings/definitions/loop.d.ts.map +1 -0
- package/dist/settings/definitions/loop.js +113 -0
- package/dist/settings/definitions/loop.js.map +1 -0
- package/dist/settings/definitions/observability.d.ts +3 -0
- package/dist/settings/definitions/observability.d.ts.map +1 -0
- package/dist/settings/definitions/observability.js +74 -0
- package/dist/settings/definitions/observability.js.map +1 -0
- package/dist/settings/definitions/security.d.ts +3 -0
- package/dist/settings/definitions/security.d.ts.map +1 -0
- package/dist/settings/definitions/security.js +121 -0
- package/dist/settings/definitions/security.js.map +1 -0
- package/dist/settings/registry.d.ts +82 -6
- package/dist/settings/registry.d.ts.map +1 -1
- package/dist/settings/registry.js +301 -194
- package/dist/settings/registry.js.map +1 -1
- package/dist/settings/runtime.d.ts +5 -1
- package/dist/settings/runtime.d.ts.map +1 -1
- package/dist/settings/runtime.js +46 -9
- package/dist/settings/runtime.js.map +1 -1
- package/dist/state/db.d.ts.map +1 -1
- package/dist/state/db.js +2 -0
- package/dist/state/db.js.map +1 -1
- package/dist/state/migrations/020-cost-ledger.d.ts +3 -0
- package/dist/state/migrations/020-cost-ledger.d.ts.map +1 -0
- package/dist/state/migrations/020-cost-ledger.js +37 -0
- package/dist/state/migrations/020-cost-ledger.js.map +1 -0
- package/dist/state/runs.d.ts +1 -0
- package/dist/state/runs.d.ts.map +1 -1
- package/dist/state/runs.js +3 -0
- package/dist/state/runs.js.map +1 -1
- package/dist/state/stats.d.ts +20 -0
- package/dist/state/stats.d.ts.map +1 -1
- package/dist/state/stats.js +68 -8
- package/dist/state/stats.js.map +1 -1
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +13 -0
- package/dist/utils/logger.js.map +1 -1
- package/dist/web/routes/api-events.d.ts +10 -0
- package/dist/web/routes/api-events.d.ts.map +1 -0
- package/dist/web/routes/api-events.js +251 -0
- package/dist/web/routes/api-events.js.map +1 -0
- package/dist/web/routes/api-operations.d.ts +3 -0
- package/dist/web/routes/api-operations.d.ts.map +1 -0
- package/dist/web/routes/api-operations.js +371 -0
- package/dist/web/routes/api-operations.js.map +1 -0
- package/dist/web/routes/api-runs.d.ts +3 -0
- package/dist/web/routes/api-runs.d.ts.map +1 -0
- package/dist/web/routes/api-runs.js +96 -0
- package/dist/web/routes/api-runs.js.map +1 -0
- package/dist/web/routes/api-settings.d.ts +3 -0
- package/dist/web/routes/api-settings.d.ts.map +1 -0
- package/dist/web/routes/api-settings.js +61 -0
- package/dist/web/routes/api-settings.js.map +1 -0
- package/dist/web/routes/context.d.ts +15 -0
- package/dist/web/routes/context.d.ts.map +1 -0
- package/dist/web/routes/context.js +2 -0
- package/dist/web/routes/context.js.map +1 -0
- package/dist/web/server.d.ts +58 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +116 -847
- package/dist/web/server.js.map +1 -1
- package/dist/web/shell-session.d.ts +74 -0
- package/dist/web/shell-session.d.ts.map +1 -0
- package/dist/web/shell-session.js +279 -0
- package/dist/web/shell-session.js.map +1 -0
- package/dist/web/snapshots.d.ts +159 -0
- package/dist/web/snapshots.d.ts.map +1 -0
- package/dist/web/snapshots.js +231 -0
- package/dist/web/snapshots.js.map +1 -0
- package/dist/workers/acp.d.ts.map +1 -1
- package/dist/workers/acp.js +116 -0
- package/dist/workers/acp.js.map +1 -1
- package/dist/workers/claude.d.ts.map +1 -1
- package/dist/workers/claude.js +13 -3
- package/dist/workers/claude.js.map +1 -1
- package/dist/workers/codex.d.ts.map +1 -1
- package/dist/workers/codex.js +16 -4
- package/dist/workers/codex.js.map +1 -1
- package/dist/workers/types.d.ts +14 -4
- package/dist/workers/types.d.ts.map +1 -1
- package/examples/config.example.yaml +12 -3
- package/package.json +8 -2
- package/web/dist/assets/index-BIrXUwFe.css +1 -0
- package/web/dist/assets/index-COMzHPcP.js +26 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-k6kgdnzy.js +0 -9
- package/web/dist/assets/index-xm9qPlYB.css +0 -1
package/dist/runner/poller.js
CHANGED
|
@@ -11,8 +11,6 @@ import { resolveEnvironmentMode, setupEnvironment, teardownEnvironment, } from '
|
|
|
11
11
|
import { createWorkerAdapter } from '../workers/factory.js';
|
|
12
12
|
import { executeLoop } from '../loop/engine.js';
|
|
13
13
|
import { resolveWorkflow } from '../loop/workflow.js';
|
|
14
|
-
import { publishPR } from '../publishing/publisher.js';
|
|
15
|
-
import { MergeConflictError } from '../publishing/push.js';
|
|
16
14
|
import { transitionLabels } from '../labels/manager.js';
|
|
17
15
|
import { buildLabelConfig } from '../labels/config.js';
|
|
18
16
|
import { NotificationDispatcher } from '../notify/dispatcher.js';
|
|
@@ -22,21 +20,31 @@ import { branchName } from '../utils/ids.js';
|
|
|
22
20
|
import { logger } from '../utils/logger.js';
|
|
23
21
|
import { nowUtcIso } from '../utils/time.js';
|
|
24
22
|
import { postPlanSummaryComment } from '../loop/plan-summary-comment.js';
|
|
25
|
-
import { executeRebase
|
|
26
|
-
import { queueContinue } from '../ops/continue.js';
|
|
23
|
+
import { executeRebase } from '../ops/rebase-and-check.js';
|
|
27
24
|
import { markerTag, upsertBotComment } from '../forge/bot-comment.js';
|
|
28
25
|
import { formatStatusComment } from '../forge/status-comment.js';
|
|
29
|
-
import { scanForReactions } from '../reactions/scanner.js';
|
|
30
|
-
import { handleReaction } from '../reactions/handler.js';
|
|
31
26
|
import { processMergeQueue } from '../merge-queue/runner.js';
|
|
32
27
|
import { decomposeIssue, shouldAttemptDecompose } from '../discovery/decomposer.js';
|
|
33
28
|
import { executeParallelSubtasks } from '../loop/parallel.js';
|
|
34
29
|
import { buildWorkerEnv } from '../workers/env.js';
|
|
35
30
|
import { isPlanningIssue } from '../planning/mode.js';
|
|
36
|
-
import { resolveIssueRepo } from '../utils/issue-repo.js';
|
|
37
|
-
import { isCommandProcessed, markCommandProcessed, parseOrchCommands, } from '../discovery/commands.js';
|
|
38
31
|
import { AgentObservability, setActiveAgentObservability, clearActiveAgentObservability, } from '../events/observability.js';
|
|
32
|
+
// Extracted modules
|
|
33
|
+
import { finalizeRunOutcome } from './run-finalizer.js';
|
|
34
|
+
import { processCommentCommands, missingCommentCommandIssues } from './comment-commands.js';
|
|
35
|
+
import { scanAndHandleReactions, reactionCursors } from './reaction-scan.js';
|
|
36
|
+
import { coerceAgentName, isImmediateFollowupStatus, applyWorkflowAgentOverrides, applyWorkflowRoleDefaults, resolveWorkerProfileForAgent, extractFollowupPromptFeedback, prioritizeDiscoveredIssues, selectReplayableRun, shouldResetBranch, makePayload, postStatusComment, postErrorStatusComment, toErrorMessage, sanitizeErrorForComment, } from './helpers.js';
|
|
39
37
|
const STATUS_MARKER = markerTag('status');
|
|
38
|
+
/**
|
|
39
|
+
* Evict entries from process-global caches that are keyed by repo+issue.
|
|
40
|
+
* Called when a run reaches a terminal state so the caches don't grow
|
|
41
|
+
* unbounded over the daemon's lifetime.
|
|
42
|
+
*/
|
|
43
|
+
function cleanupRunCaches(repo, issueNumber) {
|
|
44
|
+
const key = `${repo}#${issueNumber}`;
|
|
45
|
+
missingCommentCommandIssues.delete(key);
|
|
46
|
+
reactionCursors.delete(key);
|
|
47
|
+
}
|
|
40
48
|
/**
|
|
41
49
|
* Process one poll cycle: discover eligible issues, claim and process.
|
|
42
50
|
* Repositories are processed in parallel; each repo runs up to
|
|
@@ -175,11 +183,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
175
183
|
}
|
|
176
184
|
continue;
|
|
177
185
|
}
|
|
178
|
-
// Lease duration is deliberately short (30 min) so a
|
|
179
|
-
// crashed poller's leases expire promptly. The engine
|
|
180
|
-
// bumps the deadline on every phase checkpoint via
|
|
181
|
-
// leaseHeartbeat below, so a long run is not at risk of
|
|
182
|
-
// expiring mid-work.
|
|
183
186
|
if (!leaseManager.acquire(issueRepo, discoveredIssue.issue.number, 'poller', 1800)) {
|
|
184
187
|
continue;
|
|
185
188
|
}
|
|
@@ -244,12 +247,10 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
244
247
|
// Detect if this queued run needs a forced branch reset (e.g., after merge conflict)
|
|
245
248
|
const forceReset = activeRun?.blockReason === 'merge_conflict';
|
|
246
249
|
// Detect rebase mode from queued run's phaseData
|
|
247
|
-
// If force-resetting, ignore stale rebase context — we're starting fresh
|
|
248
250
|
const reactionType = activeRun?.phaseData?.reactionType;
|
|
249
251
|
const isRebaseRun = !forceReset && (reactionType === 'rebase' || reactionType === 'merge_conflict');
|
|
250
252
|
const followupPromptFeedback = extractFollowupPromptFeedback(activeRun?.phaseData);
|
|
251
253
|
// Check if prior run left tainted work that should be discarded
|
|
252
|
-
// Never reset to base for rebase runs — we need the existing branch
|
|
253
254
|
const planningMode = isPlanningIssue(discoveredIssue.issue.labels, repoConfigForRun);
|
|
254
255
|
const resetToBase = forceReset || (!isRebaseRun && (planningMode || shouldResetBranch(runManager, repoConfig.repo, discoveredIssue.issue.number, run.id)));
|
|
255
256
|
// Create worktree
|
|
@@ -259,14 +260,14 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
259
260
|
branchName: branch,
|
|
260
261
|
worktreePath,
|
|
261
262
|
resetToBase,
|
|
263
|
+
updateStrategy: repoConfig.updateStrategy,
|
|
262
264
|
});
|
|
263
265
|
// Execute rebase if this is a rebase-queued run
|
|
264
266
|
if (isRebaseRun) {
|
|
265
267
|
logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number, runId: run.id }, 'Executing rebase for queued rebase run');
|
|
266
268
|
const verifyCommands = repoConfig.verify ?? [];
|
|
267
|
-
const rebaseResult = await executeRebase(repoConfig.localPath, worktreePath, branch, repoConfig.baseBranch, issueRepo, discoveredIssue.issue.number, verifyCommands);
|
|
269
|
+
const rebaseResult = await executeRebase(repoConfig.localPath, worktreePath, branch, repoConfig.baseBranch, issueRepo, discoveredIssue.issue.number, verifyCommands, repoConfig.updateStrategy);
|
|
268
270
|
if (rebaseResult.conflict) {
|
|
269
|
-
// Rebase had conflicts — block the run; retry will reset the branch and re-implement
|
|
270
271
|
runManager.update(run.id, {
|
|
271
272
|
status: 'blocked',
|
|
272
273
|
blockReason: 'merge_conflict',
|
|
@@ -295,7 +296,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
295
296
|
continue;
|
|
296
297
|
}
|
|
297
298
|
if (rebaseResult.rebased && rebaseResult.verifyPassed) {
|
|
298
|
-
// Rebase succeeded and verify passes — done, transition back to review_ready
|
|
299
299
|
logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number }, 'Rebase succeeded, verify passed — returning to review_ready');
|
|
300
300
|
runManager.update(run.id, {
|
|
301
301
|
status: 'review_ready',
|
|
@@ -312,7 +312,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
312
312
|
continue;
|
|
313
313
|
}
|
|
314
314
|
if (!rebaseResult.rebased && rebaseResult.verifyPassed) {
|
|
315
|
-
// Already up-to-date and verify passes — nothing to do
|
|
316
315
|
logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number }, 'Branch already up to date — returning to review_ready');
|
|
317
316
|
runManager.update(run.id, {
|
|
318
317
|
status: 'review_ready',
|
|
@@ -325,8 +324,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
325
324
|
repoProcessed++;
|
|
326
325
|
continue;
|
|
327
326
|
}
|
|
328
|
-
// Rebase succeeded but verify failed — fall through to the loop engine
|
|
329
|
-
// so the coder can fix the issues introduced by upstream changes
|
|
330
327
|
logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number }, 'Rebase done but verify failed — entering code loop to fix');
|
|
331
328
|
}
|
|
332
329
|
if (repoConfigForRun.environment) {
|
|
@@ -377,6 +374,7 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
377
374
|
prReviewFeedback: followupPromptFeedback,
|
|
378
375
|
sessionIds: {},
|
|
379
376
|
stepOutputs: {},
|
|
377
|
+
iterationSnapshots: [],
|
|
380
378
|
};
|
|
381
379
|
// Check if decomposition is enabled and appropriate
|
|
382
380
|
const shouldDecompose = config.loop.decompose
|
|
@@ -466,17 +464,12 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
466
464
|
repoProcessed++;
|
|
467
465
|
else
|
|
468
466
|
repoErrors++;
|
|
469
|
-
// Release per-run observability resources (session log
|
|
470
|
-
// streams + event bus history) once the run has reached
|
|
471
|
-
// a terminal state — without this both Maps grow for the
|
|
472
|
-
// daemon's entire lifetime.
|
|
473
467
|
try {
|
|
474
468
|
await observability.closeRun(run.id);
|
|
475
469
|
}
|
|
476
470
|
catch (closeErr) {
|
|
477
471
|
logger.debug({ runId: run.id, err: closeErr }, 'closeRun failed (best-effort)');
|
|
478
472
|
}
|
|
479
|
-
// Evict per-issue caches so they don't grow unbounded.
|
|
480
473
|
cleanupRunCaches(repoConfig.repo, discoveredIssue.issue.number);
|
|
481
474
|
}
|
|
482
475
|
catch (err) {
|
|
@@ -494,11 +487,7 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
494
487
|
endedAt: nowUtcIso(),
|
|
495
488
|
});
|
|
496
489
|
if (canAutoRetry) {
|
|
497
|
-
// Bump retry_count on the same row so repeated replay
|
|
498
|
-
// retries converge on maxRetries. Without this the
|
|
499
|
-
// same run row cycles error → queued → error forever.
|
|
500
490
|
runManager.incrementRetryCount(runId);
|
|
501
|
-
// Auto-retry: transition back to queued so the next poll picks it up
|
|
502
491
|
logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number, attempt: attemptCount, maxRetries }, 'Infra error — auto-retrying (transitioning back to ready)');
|
|
503
492
|
try {
|
|
504
493
|
const latestIssue = await forge.getIssue(issueRepo, discoveredIssue.issue.number);
|
|
@@ -520,7 +509,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
520
509
|
});
|
|
521
510
|
}
|
|
522
511
|
else {
|
|
523
|
-
// Retries exhausted: mark as error, require human
|
|
524
512
|
logger.warn({ repo: repoConfig.repo, issue: discoveredIssue.issue.number, currentRetries, maxRetries }, 'Auto-retry limit reached — marking as error');
|
|
525
513
|
try {
|
|
526
514
|
const latestIssue = await forge.getIssue(issueRepo, discoveredIssue.issue.number);
|
|
@@ -607,754 +595,4 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
|
|
|
607
595
|
await observability.close();
|
|
608
596
|
}
|
|
609
597
|
}
|
|
610
|
-
async function finalizeRunOutcome(params) {
|
|
611
|
-
const { finalCtx, runId, issue, runDurationSec, repo, repoConfig, issueRepo, issueNumber, db, forge, runManager, notifier, metrics, maxAutoRetries, botUser, } = params;
|
|
612
|
-
if (finalCtx.terminalStatus === 'publish') {
|
|
613
|
-
try {
|
|
614
|
-
const publishResult = await publishPR(finalCtx, forge, db);
|
|
615
|
-
runManager.update(runId, {
|
|
616
|
-
status: 'review_ready',
|
|
617
|
-
iterationCount: finalCtx.iteration,
|
|
618
|
-
prNumber: publishResult.prNumber,
|
|
619
|
-
prTitle: publishResult.prTitle,
|
|
620
|
-
endedAt: nowUtcIso(),
|
|
621
|
-
});
|
|
622
|
-
const latestIssue = await forge.getIssue(issueRepo, issueNumber);
|
|
623
|
-
await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'review_ready', buildLabelConfig(repoConfig, latestIssue.labels));
|
|
624
|
-
const notificationEvent = publishResult.created ? 'pr_ready' : 'pr_updated';
|
|
625
|
-
const notifyResult = await notifier.dispatch(makePayload(notificationEvent, repo, issue, {
|
|
626
|
-
prUrl: publishResult.prUrl,
|
|
627
|
-
prNumber: publishResult.prNumber,
|
|
628
|
-
summary: publishResult.created
|
|
629
|
-
? `PR ready: ${publishResult.prUrl}`
|
|
630
|
-
: `PR updated: ${publishResult.prUrl}`,
|
|
631
|
-
}));
|
|
632
|
-
try {
|
|
633
|
-
metrics?.incRunsTotal('completed');
|
|
634
|
-
metrics?.observeRunDuration(runDurationSec);
|
|
635
|
-
metrics?.incPROperations(publishResult.created ? 'created' : 'updated');
|
|
636
|
-
for (const s of notifyResult.sent) {
|
|
637
|
-
metrics?.incNotifications(s.channel, s.success ? 'sent' : 'failed');
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
catch { /* best-effort */ }
|
|
641
|
-
return 'processed';
|
|
642
|
-
}
|
|
643
|
-
catch (err) {
|
|
644
|
-
logger.error({ err }, 'Failed to publish PR');
|
|
645
|
-
const errorMessage = toErrorMessage(err);
|
|
646
|
-
// Merge conflicts during push get structured block reason so retry resets the branch
|
|
647
|
-
if (err instanceof MergeConflictError) {
|
|
648
|
-
runManager.update(runId, {
|
|
649
|
-
status: 'blocked',
|
|
650
|
-
iterationCount: finalCtx.iteration,
|
|
651
|
-
blockReason: 'merge_conflict',
|
|
652
|
-
lastError: err.message,
|
|
653
|
-
endedAt: nowUtcIso(),
|
|
654
|
-
});
|
|
655
|
-
const latestIssue = await forge.getIssue(issueRepo, issueNumber);
|
|
656
|
-
await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'blocked', buildLabelConfig(repoConfig, latestIssue.labels), 'merge_conflict');
|
|
657
|
-
await postStatusComment({
|
|
658
|
-
forge,
|
|
659
|
-
issueRepo,
|
|
660
|
-
issueNumber,
|
|
661
|
-
botUser,
|
|
662
|
-
body: formatStatusComment({
|
|
663
|
-
blockReason: 'Publish failed due to merge conflicts while pushing branch updates.',
|
|
664
|
-
nextStep: 'Run /orch retry to reset the branch to base and re-implement on top of latest main.',
|
|
665
|
-
}),
|
|
666
|
-
warnMessage: 'Failed to post publish merge-conflict status comment',
|
|
667
|
-
});
|
|
668
|
-
try {
|
|
669
|
-
metrics?.incRunsTotal('blocked');
|
|
670
|
-
metrics?.observeRunDuration(runDurationSec);
|
|
671
|
-
}
|
|
672
|
-
catch { /* best-effort */ }
|
|
673
|
-
return 'error';
|
|
674
|
-
}
|
|
675
|
-
const currentRun = runManager.getById(runId);
|
|
676
|
-
const currentRetries = currentRun?.retryCount ?? 0;
|
|
677
|
-
runManager.update(runId, { status: 'error', iterationCount: finalCtx.iteration, lastError: errorMessage, endedAt: nowUtcIso() });
|
|
678
|
-
const latestIssue = await forge.getIssue(issueRepo, issueNumber);
|
|
679
|
-
if (currentRetries < maxAutoRetries) {
|
|
680
|
-
runManager.incrementRetryCount(runId);
|
|
681
|
-
await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'queued', buildLabelConfig(repoConfig, latestIssue.labels));
|
|
682
|
-
logger.info({ repo, issueNumber, attempt: currentRetries + 1, maxAutoRetries }, 'Publish failed — auto-retrying');
|
|
683
|
-
await postErrorStatusComment({
|
|
684
|
-
forge,
|
|
685
|
-
issueRepo,
|
|
686
|
-
issueNumber,
|
|
687
|
-
botUser,
|
|
688
|
-
error: `Publish failed. Last error: ${errorMessage}`,
|
|
689
|
-
retryCount: currentRetries + 1,
|
|
690
|
-
maxRetries: maxAutoRetries,
|
|
691
|
-
nextStep: 'Automatic retry queued. night-orch will retry this issue on the next poll cycle.',
|
|
692
|
-
warnMessage: 'Failed to post publish auto-retry status comment',
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
else {
|
|
696
|
-
await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'error', buildLabelConfig(repoConfig, latestIssue.labels));
|
|
697
|
-
const attemptCount = currentRetries + 1;
|
|
698
|
-
await postErrorStatusComment({
|
|
699
|
-
forge,
|
|
700
|
-
issueRepo,
|
|
701
|
-
issueNumber,
|
|
702
|
-
botUser,
|
|
703
|
-
error: `Failed after ${attemptCount} attempts. Last error: ${errorMessage}`,
|
|
704
|
-
retryCount: attemptCount,
|
|
705
|
-
maxRetries: maxAutoRetries,
|
|
706
|
-
nextStep: 'Manual action required: inspect the failure, then run /orch retry or /orch continue.',
|
|
707
|
-
warnMessage: 'Failed to post publish retry-exhausted status comment',
|
|
708
|
-
});
|
|
709
|
-
const sanitizedErrorForSummary = sanitizeErrorForComment(errorMessage);
|
|
710
|
-
try {
|
|
711
|
-
await notifier.dispatch(makePayload('retry_exhausted', repo, issue, {
|
|
712
|
-
summary: `Publish failed after ${attemptCount} attempts: ${sanitizedErrorForSummary}`,
|
|
713
|
-
}));
|
|
714
|
-
}
|
|
715
|
-
catch (notifyErr) {
|
|
716
|
-
logger.warn({ repo, issueNumber, err: notifyErr }, 'Failed to send publish retry exhaustion notification');
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
try {
|
|
720
|
-
metrics?.incRunsTotal('error');
|
|
721
|
-
metrics?.observeRunDuration(runDurationSec);
|
|
722
|
-
}
|
|
723
|
-
catch { /* best-effort */ }
|
|
724
|
-
return 'error';
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
if (finalCtx.terminalStatus === 'blocked') {
|
|
728
|
-
const blockReason = buildBlockReason(finalCtx);
|
|
729
|
-
runManager.update(runId, {
|
|
730
|
-
status: 'blocked',
|
|
731
|
-
iterationCount: finalCtx.iteration,
|
|
732
|
-
lastError: blockReason,
|
|
733
|
-
blockReason: finalCtx.blockReason ?? null,
|
|
734
|
-
endedAt: nowUtcIso(),
|
|
735
|
-
});
|
|
736
|
-
const latestIssue = await forge.getIssue(issueRepo, issueNumber);
|
|
737
|
-
await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'blocked', buildLabelConfig(repoConfig, latestIssue.labels), finalCtx.blockReason ?? undefined);
|
|
738
|
-
// Upsert status comment with block reason
|
|
739
|
-
try {
|
|
740
|
-
const statusBody = formatStatusComment({
|
|
741
|
-
blockReason,
|
|
742
|
-
iteration: finalCtx.iteration,
|
|
743
|
-
maxIterations: finalCtx.adjustedLimits.maxReviewIterations,
|
|
744
|
-
cost: finalCtx.estimatedCostUsd,
|
|
745
|
-
});
|
|
746
|
-
if (botUser) {
|
|
747
|
-
await upsertBotComment(forge, issueRepo, issueNumber, STATUS_MARKER, statusBody, botUser);
|
|
748
|
-
}
|
|
749
|
-
else {
|
|
750
|
-
await forge.commentOnIssue(issueRepo, issueNumber, formatBlockComment(blockReason, finalCtx));
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
catch (commentErr) {
|
|
754
|
-
logger.warn({ repo, issueNumber, err: commentErr }, 'Failed to post block reason comment');
|
|
755
|
-
}
|
|
756
|
-
const notifyResult = await notifier.dispatch(makePayload('blocked', repo, issue, {
|
|
757
|
-
summary: blockReason,
|
|
758
|
-
blockingReason: blockReason,
|
|
759
|
-
reviewSummary: finalCtx.reviewResult?.summary ?? null,
|
|
760
|
-
}));
|
|
761
|
-
try {
|
|
762
|
-
metrics?.incRunsTotal('blocked');
|
|
763
|
-
metrics?.observeRunDuration(runDurationSec);
|
|
764
|
-
for (const s of notifyResult.sent) {
|
|
765
|
-
metrics?.incNotifications(s.channel, s.success ? 'sent' : 'failed');
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
catch { /* best-effort */ }
|
|
769
|
-
return 'processed';
|
|
770
|
-
}
|
|
771
|
-
const unexpectedError = `Loop ended in unexpected state: ${finalCtx.terminalStatus}/${finalCtx.currentPhase}`;
|
|
772
|
-
const currentRunForUnexpected = runManager.getById(runId);
|
|
773
|
-
const currentRetriesUnexpected = currentRunForUnexpected?.retryCount ?? 0;
|
|
774
|
-
runManager.update(runId, { status: 'error', iterationCount: finalCtx.iteration, lastError: unexpectedError, endedAt: nowUtcIso() });
|
|
775
|
-
const latestIssue = await forge.getIssue(issueRepo, issueNumber);
|
|
776
|
-
if (currentRetriesUnexpected < maxAutoRetries) {
|
|
777
|
-
runManager.incrementRetryCount(runId);
|
|
778
|
-
await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'queued', buildLabelConfig(repoConfig, latestIssue.labels));
|
|
779
|
-
logger.info({ repo, issueNumber, attempt: currentRetriesUnexpected + 1, maxAutoRetries }, 'Unexpected state — auto-retrying');
|
|
780
|
-
await postErrorStatusComment({
|
|
781
|
-
forge,
|
|
782
|
-
issueRepo,
|
|
783
|
-
issueNumber,
|
|
784
|
-
botUser,
|
|
785
|
-
error: `Loop entered unexpected state: ${finalCtx.terminalStatus}/${finalCtx.currentPhase}`,
|
|
786
|
-
retryCount: currentRetriesUnexpected + 1,
|
|
787
|
-
maxRetries: maxAutoRetries,
|
|
788
|
-
nextStep: 'Automatic retry queued. night-orch will retry this issue on the next poll cycle.',
|
|
789
|
-
warnMessage: 'Failed to post unexpected-state auto-retry status comment',
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
else {
|
|
793
|
-
await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'error', buildLabelConfig(repoConfig, latestIssue.labels));
|
|
794
|
-
const attemptCount = currentRetriesUnexpected + 1;
|
|
795
|
-
await postErrorStatusComment({
|
|
796
|
-
forge,
|
|
797
|
-
issueRepo,
|
|
798
|
-
issueNumber,
|
|
799
|
-
botUser,
|
|
800
|
-
error: `Failed after ${attemptCount} attempts. Last error: ${unexpectedError}`,
|
|
801
|
-
retryCount: attemptCount,
|
|
802
|
-
maxRetries: maxAutoRetries,
|
|
803
|
-
nextStep: 'Manual action required: inspect the failure, then run /orch retry or /orch continue.',
|
|
804
|
-
warnMessage: 'Failed to post unexpected-state retry-exhausted status comment',
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
try {
|
|
808
|
-
metrics?.incRunsTotal('error');
|
|
809
|
-
metrics?.observeRunDuration(runDurationSec);
|
|
810
|
-
}
|
|
811
|
-
catch { /* best-effort */ }
|
|
812
|
-
return 'error';
|
|
813
|
-
}
|
|
814
|
-
function coerceAgentName(value, fallback) {
|
|
815
|
-
if (value === 'claude' || value === 'codex' || value === 'opencode') {
|
|
816
|
-
return value;
|
|
817
|
-
}
|
|
818
|
-
return fallback;
|
|
819
|
-
}
|
|
820
|
-
function isImmediateFollowupStatus(status) {
|
|
821
|
-
return status === 'review_ready'
|
|
822
|
-
|| status === 'blocked'
|
|
823
|
-
|| status === 'error'
|
|
824
|
-
|| status === 'completed';
|
|
825
|
-
}
|
|
826
|
-
function applyWorkflowAgentOverrides(repoConfig, workflow) {
|
|
827
|
-
if (!workflow.agents || Object.keys(workflow.agents).length === 0) {
|
|
828
|
-
return repoConfig;
|
|
829
|
-
}
|
|
830
|
-
return {
|
|
831
|
-
...repoConfig,
|
|
832
|
-
agents: {
|
|
833
|
-
...repoConfig.agents,
|
|
834
|
-
...workflow.agents,
|
|
835
|
-
},
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
function applyWorkflowRoleDefaults(repoDefaults, workflow, repoConfig, config) {
|
|
839
|
-
if (!workflow.roles) {
|
|
840
|
-
return repoDefaults;
|
|
841
|
-
}
|
|
842
|
-
const merged = {
|
|
843
|
-
...repoDefaults,
|
|
844
|
-
...workflow.roles,
|
|
845
|
-
};
|
|
846
|
-
for (const role of ['planner', 'coder', 'reviewer']) {
|
|
847
|
-
const preferredAgent = merged[role];
|
|
848
|
-
if (canResolveAgent(preferredAgent, repoConfig, config))
|
|
849
|
-
continue;
|
|
850
|
-
merged[role] = repoDefaults[role];
|
|
851
|
-
}
|
|
852
|
-
return merged;
|
|
853
|
-
}
|
|
854
|
-
function canResolveAgent(agent, repoConfig, config) {
|
|
855
|
-
return resolveWorkerProfileForAgent(agent, repoConfig, config) !== null;
|
|
856
|
-
}
|
|
857
|
-
function resolveWorkerProfileForAgent(agent, repoConfig, config) {
|
|
858
|
-
const mappedProfileName = repoConfig.agents[agent];
|
|
859
|
-
if (mappedProfileName) {
|
|
860
|
-
const mappedProfile = config.workerProfiles[mappedProfileName];
|
|
861
|
-
if (mappedProfile)
|
|
862
|
-
return mappedProfile;
|
|
863
|
-
}
|
|
864
|
-
return Object.values(config.workerProfiles).find((profile) => profile.type === agent) ?? null;
|
|
865
|
-
}
|
|
866
|
-
function buildBlockReason(ctx) {
|
|
867
|
-
const blockMessage = ctx.stepOutputs?.['blockMessage'];
|
|
868
|
-
if (typeof blockMessage === 'string' && blockMessage.trim().length > 0) {
|
|
869
|
-
return blockMessage;
|
|
870
|
-
}
|
|
871
|
-
if (ctx.reviewResult) {
|
|
872
|
-
const findings = ctx.reviewResult.findings
|
|
873
|
-
.filter((f) => f.severity === 'critical' || f.severity === 'major')
|
|
874
|
-
.map((f) => `[${f.severity}] ${f.message}`)
|
|
875
|
-
.join('; ');
|
|
876
|
-
return findings
|
|
877
|
-
? `${ctx.reviewResult.summary} — ${findings}`
|
|
878
|
-
: ctx.reviewResult.summary;
|
|
879
|
-
}
|
|
880
|
-
if (ctx.blockReason) {
|
|
881
|
-
return blockReasonSummary(ctx.blockReason, ctx);
|
|
882
|
-
}
|
|
883
|
-
return `Blocked in phase ${ctx.currentPhase} (no review result available)`;
|
|
884
|
-
}
|
|
885
|
-
function blockReasonSummary(reason, ctx) {
|
|
886
|
-
switch (reason) {
|
|
887
|
-
case 'cost_limit':
|
|
888
|
-
// The engine writes a precise, limit-specific message into stepOutputs.blockMessage
|
|
889
|
-
// (see src/loop/engine.ts cost check). This branch is only reached when that
|
|
890
|
-
// structured message is missing — keep it vague so we do not claim the per-run
|
|
891
|
-
// limit tripped when it might have been the daily cap.
|
|
892
|
-
return `Cost limit exceeded for this run (estimated run cost: $${ctx.estimatedCostUsd.toFixed(4)})`;
|
|
893
|
-
case 'iteration_limit':
|
|
894
|
-
return `Maximum review iterations reached (${ctx.iteration}/${ctx.adjustedLimits.maxReviewIterations})`;
|
|
895
|
-
case 'agent_pass_limit':
|
|
896
|
-
return `Maximum total agent passes reached (${ctx.totalAgentPasses}/${ctx.adjustedLimits.maxTotalAgentPasses})`;
|
|
897
|
-
case 'reviewer_blocked':
|
|
898
|
-
return 'Reviewer marked this run as blocked';
|
|
899
|
-
case 'ambiguous_review':
|
|
900
|
-
return 'Review output was not parseable and blockOnAmbiguousReview is enabled';
|
|
901
|
-
case 'verify_config':
|
|
902
|
-
return 'Verification is required but verify commands or results are unavailable';
|
|
903
|
-
case 'merge_conflict':
|
|
904
|
-
return 'Merge conflict encountered while applying updates';
|
|
905
|
-
case 'auth_failure':
|
|
906
|
-
return 'Worker CLI authentication expired — re-authenticate and retry';
|
|
907
|
-
default:
|
|
908
|
-
return `Blocked in phase ${ctx.currentPhase}`;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
function formatBlockComment(reason, ctx) {
|
|
912
|
-
const parts = [`⛔ **night-orch**: Run blocked.\n\n**Reason:** ${reason}`];
|
|
913
|
-
if (ctx.reviewResult?.findings && ctx.reviewResult.findings.length > 0) {
|
|
914
|
-
parts.push('\n**Findings:**');
|
|
915
|
-
for (const f of ctx.reviewResult.findings) {
|
|
916
|
-
const fix = f.suggestedFix ? ` → ${f.suggestedFix}` : '';
|
|
917
|
-
parts.push(`- **${f.severity}**: ${f.message}${fix}`);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
parts.push(`\n*Iteration ${ctx.iteration}, cost: $${ctx.estimatedCostUsd.toFixed(4)}*`);
|
|
921
|
-
return parts.join('\n');
|
|
922
|
-
}
|
|
923
|
-
function makePayload(event, repo, issue, extra = {}) {
|
|
924
|
-
return {
|
|
925
|
-
event,
|
|
926
|
-
repo,
|
|
927
|
-
issueNumber: issue.number,
|
|
928
|
-
issueTitle: issue.title,
|
|
929
|
-
issueUrl: issue.url ?? null,
|
|
930
|
-
state: event,
|
|
931
|
-
prUrl: null,
|
|
932
|
-
prNumber: null,
|
|
933
|
-
summary: `${event}: #${issue.number} ${issue.title}`,
|
|
934
|
-
blockingReason: null,
|
|
935
|
-
reviewSummary: null,
|
|
936
|
-
iterationCount: 0,
|
|
937
|
-
timestamp: nowUtcIso(),
|
|
938
|
-
...extra,
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
async function postStatusComment(params) {
|
|
942
|
-
const { forge, issueRepo, issueNumber, botUser, body, warnMessage, } = params;
|
|
943
|
-
try {
|
|
944
|
-
if (botUser) {
|
|
945
|
-
await upsertBotComment(forge, issueRepo, issueNumber, STATUS_MARKER, body, botUser);
|
|
946
|
-
}
|
|
947
|
-
else {
|
|
948
|
-
await forge.commentOnIssue(issueRepo, issueNumber, body);
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
catch (commentErr) {
|
|
952
|
-
logger.warn({ repo: issueRepo, issueNumber, err: commentErr }, warnMessage);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
const ERROR_COMMENT_MAX_LENGTH = 400;
|
|
956
|
-
const TOKEN_REDACTION_PATTERNS = [
|
|
957
|
-
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
|
|
958
|
-
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
959
|
-
/\bsk-[A-Za-z0-9]{20,}\b/g,
|
|
960
|
-
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
961
|
-
/\bASIA[0-9A-Z]{16}\b/g,
|
|
962
|
-
/\bAIza[0-9A-Za-z\-_]{20,}\b/g,
|
|
963
|
-
/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g,
|
|
964
|
-
];
|
|
965
|
-
async function postErrorStatusComment(params) {
|
|
966
|
-
const { forge, issueRepo, issueNumber, botUser, error, retryCount, maxRetries, nextStep, warnMessage, } = params;
|
|
967
|
-
const sanitizedError = sanitizeErrorForComment(error);
|
|
968
|
-
const body = formatStatusComment({
|
|
969
|
-
error: sanitizedError,
|
|
970
|
-
retryCount,
|
|
971
|
-
maxRetries,
|
|
972
|
-
nextStep,
|
|
973
|
-
});
|
|
974
|
-
await postStatusComment({
|
|
975
|
-
forge,
|
|
976
|
-
issueRepo,
|
|
977
|
-
issueNumber,
|
|
978
|
-
botUser,
|
|
979
|
-
body,
|
|
980
|
-
warnMessage,
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
function toErrorMessage(err) {
|
|
984
|
-
if (err instanceof Error && typeof err.message === 'string' && err.message.trim().length > 0) {
|
|
985
|
-
return err.message;
|
|
986
|
-
}
|
|
987
|
-
return String(err);
|
|
988
|
-
}
|
|
989
|
-
function sanitizeErrorForComment(errorMessage) {
|
|
990
|
-
let sanitized = errorMessage
|
|
991
|
-
.replace(/[\r\n]+/g, ' ');
|
|
992
|
-
sanitized = stripControlChars(sanitized);
|
|
993
|
-
sanitized = sanitized
|
|
994
|
-
.replace(/\b(token|secret|password|passwd|api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, '$1=[REDACTED]')
|
|
995
|
-
.trim();
|
|
996
|
-
for (const pattern of TOKEN_REDACTION_PATTERNS) {
|
|
997
|
-
sanitized = sanitized.replace(pattern, '[REDACTED]');
|
|
998
|
-
}
|
|
999
|
-
sanitized = sanitized.replace(/\s+/g, ' ').trim();
|
|
1000
|
-
if (!sanitized)
|
|
1001
|
-
return 'unknown error';
|
|
1002
|
-
const clipped = sanitized.length > ERROR_COMMENT_MAX_LENGTH
|
|
1003
|
-
? `${sanitized.slice(0, ERROR_COMMENT_MAX_LENGTH - 1)}…`
|
|
1004
|
-
: sanitized;
|
|
1005
|
-
return escapeMarkdownForComment(clipped);
|
|
1006
|
-
}
|
|
1007
|
-
function escapeMarkdownForComment(value) {
|
|
1008
|
-
return value
|
|
1009
|
-
.replace(/\\/g, '\\\\')
|
|
1010
|
-
.replace(/([`*_#[\]])/g, '\\$1')
|
|
1011
|
-
.replace(/</g, '<')
|
|
1012
|
-
.replace(/>/g, '>')
|
|
1013
|
-
.replace(/@/g, '@\u200B');
|
|
1014
|
-
}
|
|
1015
|
-
function stripControlChars(value) {
|
|
1016
|
-
let out = '';
|
|
1017
|
-
for (const ch of value) {
|
|
1018
|
-
const code = ch.charCodeAt(0);
|
|
1019
|
-
if ((code >= 0 && code <= 31) || code === 127) {
|
|
1020
|
-
out += ' ';
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
out += ch;
|
|
1024
|
-
}
|
|
1025
|
-
return out;
|
|
1026
|
-
}
|
|
1027
|
-
/** Issues that returned 404 during comment scan in this process lifecycle.
|
|
1028
|
-
* Bounded: entries are evicted when the key's run reaches a terminal state
|
|
1029
|
-
* via {@link cleanupRunCaches}. */
|
|
1030
|
-
const missingCommentCommandIssues = new Set();
|
|
1031
|
-
function getHttpStatus(err) {
|
|
1032
|
-
if (typeof err !== 'object' || err === null)
|
|
1033
|
-
return null;
|
|
1034
|
-
const e = err;
|
|
1035
|
-
if (typeof e.status === 'number')
|
|
1036
|
-
return e.status;
|
|
1037
|
-
if (typeof e.response?.status === 'number')
|
|
1038
|
-
return e.response.status;
|
|
1039
|
-
return null;
|
|
1040
|
-
}
|
|
1041
|
-
async function processCommentCommands(params) {
|
|
1042
|
-
const { config, db, forge, runManager, leaseManager, repoConfig, botUser, } = params;
|
|
1043
|
-
const commandSettings = config.commentCommands ?? { enabled: true, requireCollaborator: false };
|
|
1044
|
-
if (!commandSettings.enabled)
|
|
1045
|
-
return;
|
|
1046
|
-
// Warn once per poll when comment commands accept non-collaborators —
|
|
1047
|
-
// for public repos this means any GitHub user can trigger retry/rebase/
|
|
1048
|
-
// continue/delete operations.
|
|
1049
|
-
if (!commandSettings.requireCollaborator) {
|
|
1050
|
-
logger.warn({ repo: repoConfig.repo }, 'commentCommands.requireCollaborator=false — /orch commands accept any commenter. Enable on public repos.');
|
|
1051
|
-
}
|
|
1052
|
-
const activeRuns = runManager
|
|
1053
|
-
.getActive()
|
|
1054
|
-
.filter((run) => run.repo === repoConfig.repo);
|
|
1055
|
-
const issueRows = [...new Map(activeRuns.map((run) => {
|
|
1056
|
-
const issueRepo = resolveIssueRepo(run.phaseData, repoConfig.repo);
|
|
1057
|
-
return [`${issueRepo}#${run.issueNumber}`, { issue_number: run.issueNumber, issue_repo: issueRepo }];
|
|
1058
|
-
})).values()]
|
|
1059
|
-
.sort((a, b) => a.issue_repo.localeCompare(b.issue_repo) || a.issue_number - b.issue_number);
|
|
1060
|
-
if (issueRows.length === 0)
|
|
1061
|
-
return;
|
|
1062
|
-
const collaboratorCache = new Map();
|
|
1063
|
-
for (const row of issueRows) {
|
|
1064
|
-
const issueKey = `${row.issue_repo}#${row.issue_number}`;
|
|
1065
|
-
if (missingCommentCommandIssues.has(issueKey)) {
|
|
1066
|
-
continue;
|
|
1067
|
-
}
|
|
1068
|
-
let comments;
|
|
1069
|
-
try {
|
|
1070
|
-
comments = await forge.listIssueComments(row.issue_repo, row.issue_number);
|
|
1071
|
-
}
|
|
1072
|
-
catch (err) {
|
|
1073
|
-
if (getHttpStatus(err) === 404) {
|
|
1074
|
-
missingCommentCommandIssues.add(issueKey);
|
|
1075
|
-
logger.debug({ repo: row.issue_repo, issueNumber: row.issue_number }, 'Skipping comment command scan for missing or inaccessible issue');
|
|
1076
|
-
continue;
|
|
1077
|
-
}
|
|
1078
|
-
throw err;
|
|
1079
|
-
}
|
|
1080
|
-
const parsed = parseOrchCommands(comments, '1970-01-01T00:00:00Z');
|
|
1081
|
-
for (const item of parsed) {
|
|
1082
|
-
if (isCommandProcessed(db, row.issue_repo, row.issue_number, item.commentId))
|
|
1083
|
-
continue;
|
|
1084
|
-
// Track whether the command reached a terminal outcome — applied,
|
|
1085
|
-
// denied (policy decision), or rejected (validated failure). Only
|
|
1086
|
-
// terminal outcomes mark the command as processed; transient
|
|
1087
|
-
// failures must remain retriable on the next poll cycle.
|
|
1088
|
-
let commandStatus = null;
|
|
1089
|
-
try {
|
|
1090
|
-
const allowed = await canExecuteCommentCommand({
|
|
1091
|
-
forge,
|
|
1092
|
-
repo: row.issue_repo,
|
|
1093
|
-
user: item.user,
|
|
1094
|
-
requireCollaborator: commandSettings.requireCollaborator,
|
|
1095
|
-
cache: collaboratorCache,
|
|
1096
|
-
});
|
|
1097
|
-
if (!allowed) {
|
|
1098
|
-
commandStatus = 'denied';
|
|
1099
|
-
logger.info({ repo: repoConfig.repo, issueNumber: row.issue_number, user: item.user, commentId: item.commentId }, 'Ignoring comment command from non-collaborator');
|
|
1100
|
-
continue;
|
|
1101
|
-
}
|
|
1102
|
-
const result = await executeCommentCommand({
|
|
1103
|
-
command: item.command,
|
|
1104
|
-
db,
|
|
1105
|
-
forge,
|
|
1106
|
-
runManager,
|
|
1107
|
-
leaseManager,
|
|
1108
|
-
repoConfig,
|
|
1109
|
-
issueRepo: row.issue_repo,
|
|
1110
|
-
issueNumber: row.issue_number,
|
|
1111
|
-
botUser,
|
|
1112
|
-
user: item.user,
|
|
1113
|
-
});
|
|
1114
|
-
if (!result.ok) {
|
|
1115
|
-
commandStatus = 'rejected';
|
|
1116
|
-
logger.info({ repo: repoConfig.repo, issueNumber: row.issue_number, command: item.command.type, reason: result.reason }, 'Comment command rejected');
|
|
1117
|
-
}
|
|
1118
|
-
else {
|
|
1119
|
-
commandStatus = 'applied';
|
|
1120
|
-
logger.info({ repo: repoConfig.repo, issueNumber: row.issue_number, command: item.command.type, user: item.user }, 'Comment command applied');
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
catch (err) {
|
|
1124
|
-
// Transient failure: leave commandStatus=null so the command
|
|
1125
|
-
// remains unprocessed and will be retried on the next poll.
|
|
1126
|
-
logger.warn({ repo: repoConfig.repo, issueNumber: row.issue_number, commentId: item.commentId, command: item.command.type, err }, 'Comment command failed (transient — will retry)');
|
|
1127
|
-
}
|
|
1128
|
-
finally {
|
|
1129
|
-
if (commandStatus !== null) {
|
|
1130
|
-
markCommandProcessed(db, row.issue_repo, row.issue_number, item.commentId, `${item.command.type}:${commandStatus}`);
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
async function canExecuteCommentCommand(params) {
|
|
1137
|
-
const { forge, repo, user, requireCollaborator, cache } = params;
|
|
1138
|
-
if (!requireCollaborator)
|
|
1139
|
-
return true;
|
|
1140
|
-
if (!user)
|
|
1141
|
-
return false;
|
|
1142
|
-
// Cache key must include the repo — a user might be a collaborator on
|
|
1143
|
-
// one linked project but not another, and reusing a single-user cache
|
|
1144
|
-
// across repos in the same scan would erroneously grant or deny access.
|
|
1145
|
-
const cacheKey = `${repo}\n${user}`;
|
|
1146
|
-
const cached = cache.get(cacheKey);
|
|
1147
|
-
if (cached !== undefined)
|
|
1148
|
-
return cached;
|
|
1149
|
-
if (!forge.isCollaborator) {
|
|
1150
|
-
logger.warn({ repo, user }, 'requireCollaborator=true but forge adapter has no isCollaborator() implementation');
|
|
1151
|
-
cache.set(cacheKey, false);
|
|
1152
|
-
return false;
|
|
1153
|
-
}
|
|
1154
|
-
try {
|
|
1155
|
-
const allowed = await forge.isCollaborator(repo, user);
|
|
1156
|
-
cache.set(cacheKey, allowed);
|
|
1157
|
-
return allowed;
|
|
1158
|
-
}
|
|
1159
|
-
catch (err) {
|
|
1160
|
-
logger.warn({ repo, user, err }, 'Failed collaborator check for comment command user');
|
|
1161
|
-
cache.set(cacheKey, false);
|
|
1162
|
-
return false;
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
async function executeCommentCommand(params) {
|
|
1166
|
-
const { command, db, forge, runManager, leaseManager, repoConfig, issueRepo, issueNumber, botUser, user, } = params;
|
|
1167
|
-
switch (command.type) {
|
|
1168
|
-
case 'retry':
|
|
1169
|
-
return queueRetryFromComment({
|
|
1170
|
-
runManager,
|
|
1171
|
-
leaseManager,
|
|
1172
|
-
forge,
|
|
1173
|
-
repoConfig,
|
|
1174
|
-
issueRepo,
|
|
1175
|
-
issueNumber,
|
|
1176
|
-
resetPlan: command.resetPlan,
|
|
1177
|
-
});
|
|
1178
|
-
case 'continue':
|
|
1179
|
-
{
|
|
1180
|
-
const result = await queueContinue(db, forge, repoConfig, issueNumber, botUser, { issueRepo });
|
|
1181
|
-
return result.queued ? { ok: true } : { ok: false, reason: result.reason };
|
|
1182
|
-
}
|
|
1183
|
-
case 'rebase': {
|
|
1184
|
-
// queueRebase currently always verifies after rebase; keep behavior stable.
|
|
1185
|
-
const result = await queueRebase(db, forge, repoConfig, issueNumber, botUser);
|
|
1186
|
-
return result.queued ? { ok: true } : { ok: false, reason: result.reason };
|
|
1187
|
-
}
|
|
1188
|
-
case 'cancel':
|
|
1189
|
-
return cancelRunFromComment({
|
|
1190
|
-
runManager,
|
|
1191
|
-
leaseManager,
|
|
1192
|
-
forge,
|
|
1193
|
-
repoConfig,
|
|
1194
|
-
issueRepo,
|
|
1195
|
-
issueNumber,
|
|
1196
|
-
user,
|
|
1197
|
-
});
|
|
1198
|
-
default: {
|
|
1199
|
-
const exhaustive = command;
|
|
1200
|
-
return { ok: false, reason: `Unsupported command: ${String(exhaustive)}` };
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
async function queueRetryFromComment(params) {
|
|
1205
|
-
const { runManager, leaseManager, forge, repoConfig, issueRepo, issueNumber, resetPlan } = params;
|
|
1206
|
-
const run = runManager.getByRepoAndIssue(repoConfig.repo, issueNumber);
|
|
1207
|
-
if (!run)
|
|
1208
|
-
return { ok: false, reason: 'No run found for issue' };
|
|
1209
|
-
if (run.status === 'running')
|
|
1210
|
-
return { ok: false, reason: 'Run is currently running' };
|
|
1211
|
-
if (!['blocked', 'error', 'review_ready'].includes(run.status)) {
|
|
1212
|
-
return { ok: false, reason: `Retry not allowed from status ${run.status}` };
|
|
1213
|
-
}
|
|
1214
|
-
runManager.update(run.id, {
|
|
1215
|
-
status: 'queued',
|
|
1216
|
-
currentPhase: null,
|
|
1217
|
-
endedAt: null,
|
|
1218
|
-
lastError: null,
|
|
1219
|
-
phaseData: resetPlan ? null : run.phaseData,
|
|
1220
|
-
blockReason: null,
|
|
1221
|
-
});
|
|
1222
|
-
leaseManager.release(issueRepo, issueNumber);
|
|
1223
|
-
if (issueRepo !== repoConfig.repo) {
|
|
1224
|
-
leaseManager.release(repoConfig.repo, issueNumber);
|
|
1225
|
-
}
|
|
1226
|
-
const issue = await forge.getIssue(issueRepo, issueNumber);
|
|
1227
|
-
await transitionLabels(forge, issueRepo, issueNumber, issue.labels, run.status, 'queued', buildLabelConfig(repoConfig, issue.labels));
|
|
1228
|
-
return { ok: true };
|
|
1229
|
-
}
|
|
1230
|
-
async function cancelRunFromComment(params) {
|
|
1231
|
-
const { runManager, leaseManager, forge, repoConfig, issueRepo, issueNumber, user } = params;
|
|
1232
|
-
const run = runManager.getByRepoAndIssue(repoConfig.repo, issueNumber);
|
|
1233
|
-
if (!run)
|
|
1234
|
-
return { ok: false, reason: 'No run found for issue' };
|
|
1235
|
-
if (run.status !== 'running' && run.status !== 'queued') {
|
|
1236
|
-
return { ok: false, reason: `Cancel only supports running/queued runs (current: ${run.status})` };
|
|
1237
|
-
}
|
|
1238
|
-
runManager.update(run.id, {
|
|
1239
|
-
status: 'blocked',
|
|
1240
|
-
endedAt: nowUtcIso(),
|
|
1241
|
-
lastError: `Cancelled by @${user} via comment command`,
|
|
1242
|
-
blockReason: null,
|
|
1243
|
-
});
|
|
1244
|
-
leaseManager.release(issueRepo, issueNumber);
|
|
1245
|
-
if (issueRepo !== repoConfig.repo) {
|
|
1246
|
-
leaseManager.release(repoConfig.repo, issueNumber);
|
|
1247
|
-
}
|
|
1248
|
-
const issue = await forge.getIssue(issueRepo, issueNumber);
|
|
1249
|
-
await transitionLabels(forge, issueRepo, issueNumber, issue.labels, run.status, 'blocked', buildLabelConfig(repoConfig, issue.labels));
|
|
1250
|
-
return { ok: true };
|
|
1251
|
-
}
|
|
1252
|
-
/**
|
|
1253
|
-
* Block reasons that indicate the coder's work is broken and the branch
|
|
1254
|
-
* should be hard-reset to base on the next attempt. Salvageable states
|
|
1255
|
-
* (reviewer_blocked, iteration_limit, ambiguous_review, verify_config)
|
|
1256
|
-
* preserve the branch so existing work can be continued.
|
|
1257
|
-
*/
|
|
1258
|
-
const TAINTED_BLOCK_REASONS = new Set(['agent_pass_limit', 'cost_limit', 'merge_conflict', 'auth_failure']);
|
|
1259
|
-
// --- Reaction scanning ---
|
|
1260
|
-
/** In-memory reaction cursors, keyed by "repo#issueNumber".
|
|
1261
|
-
* Bounded: entries are evicted via {@link cleanupRunCaches}. */
|
|
1262
|
-
const reactionCursors = new Map();
|
|
1263
|
-
/**
|
|
1264
|
-
* Evict entries from process-global caches that are keyed by repo+issue.
|
|
1265
|
-
* Called when a run reaches a terminal state so the caches don't grow
|
|
1266
|
-
* unbounded over the daemon's lifetime.
|
|
1267
|
-
*/
|
|
1268
|
-
function cleanupRunCaches(repo, issueNumber) {
|
|
1269
|
-
const key = `${repo}#${issueNumber}`;
|
|
1270
|
-
missingCommentCommandIssues.delete(key);
|
|
1271
|
-
reactionCursors.delete(key);
|
|
1272
|
-
}
|
|
1273
|
-
async function scanAndHandleReactions(params) {
|
|
1274
|
-
const { db, forge, runManager, repoConfig, botUser } = params;
|
|
1275
|
-
// Find review_ready issues with PRs for this repo.
|
|
1276
|
-
const rows = runManager
|
|
1277
|
-
.getActive()
|
|
1278
|
-
.filter((run) => run.repo === repoConfig.repo && run.status === 'review_ready' && run.prNumber !== null)
|
|
1279
|
-
.map((run) => ({
|
|
1280
|
-
id: run.id,
|
|
1281
|
-
repo: run.repo,
|
|
1282
|
-
issue_number: run.issueNumber,
|
|
1283
|
-
pr_number: run.prNumber,
|
|
1284
|
-
}));
|
|
1285
|
-
for (const row of rows) {
|
|
1286
|
-
const cursorKey = `${row.repo}#${row.issue_number}`;
|
|
1287
|
-
const cursor = reactionCursors.get(cursorKey);
|
|
1288
|
-
const result = await scanForReactions(forge, row.repo, row.pr_number, row.issue_number, botUser, cursor);
|
|
1289
|
-
// Update cursor regardless of reactions
|
|
1290
|
-
reactionCursors.set(cursorKey, result.cursor);
|
|
1291
|
-
// Handle each reaction
|
|
1292
|
-
for (const reaction of result.reactions) {
|
|
1293
|
-
try {
|
|
1294
|
-
await handleReaction(reaction, { db, forge, runManager, repoConfig });
|
|
1295
|
-
}
|
|
1296
|
-
catch (err) {
|
|
1297
|
-
logger.warn({ repo: row.repo, issueNumber: row.issue_number, reactionType: reaction.type, err }, 'Failed to handle reaction');
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
function extractFollowupPromptFeedback(phaseData) {
|
|
1303
|
-
if (!phaseData)
|
|
1304
|
-
return null;
|
|
1305
|
-
const context = phaseData['reactionContext'];
|
|
1306
|
-
if (typeof context !== 'string' || context.trim().length === 0)
|
|
1307
|
-
return null;
|
|
1308
|
-
const type = typeof phaseData['reactionType'] === 'string' && phaseData['reactionType'].trim().length > 0
|
|
1309
|
-
? phaseData['reactionType']
|
|
1310
|
-
: 'continue';
|
|
1311
|
-
const summary = typeof phaseData['reactionSummary'] === 'string' && phaseData['reactionSummary'].trim().length > 0
|
|
1312
|
-
? phaseData['reactionSummary']
|
|
1313
|
-
: 'Follow-up context available';
|
|
1314
|
-
return { type, summary, context };
|
|
1315
|
-
}
|
|
1316
|
-
/**
|
|
1317
|
-
* Prioritize follow-up work over fresh issues so reactive runs (especially
|
|
1318
|
-
* merge conflict rebases) are handled promptly and don't starve behind newer
|
|
1319
|
-
* ready issues.
|
|
1320
|
-
*/
|
|
1321
|
-
function prioritizeDiscoveredIssues(runManager, repo, discovered) {
|
|
1322
|
-
const ranked = discovered.map((item) => ({
|
|
1323
|
-
item,
|
|
1324
|
-
rank: getIssueQueuePriority(runManager, repo, item.issue.number),
|
|
1325
|
-
}));
|
|
1326
|
-
ranked.sort((a, b) => a.rank - b.rank);
|
|
1327
|
-
return ranked.map((entry) => entry.item);
|
|
1328
|
-
}
|
|
1329
|
-
function getIssueQueuePriority(runManager, repo, issueNumber) {
|
|
1330
|
-
const queuedRun = runManager.getLatestQueuedByIssue(repo, issueNumber);
|
|
1331
|
-
if (!queuedRun)
|
|
1332
|
-
return 3;
|
|
1333
|
-
const reactionType = queuedRun.phaseData?.reactionType;
|
|
1334
|
-
if (reactionType === 'merge_conflict' || reactionType === 'rebase')
|
|
1335
|
-
return 0;
|
|
1336
|
-
if (typeof reactionType === 'string' && reactionType.length > 0)
|
|
1337
|
-
return 1;
|
|
1338
|
-
return 2;
|
|
1339
|
-
}
|
|
1340
|
-
function selectReplayableRun(run) {
|
|
1341
|
-
if (!run)
|
|
1342
|
-
return null;
|
|
1343
|
-
if (run.status === 'blocked' || run.status === 'review_ready' || run.status === 'error') {
|
|
1344
|
-
return run;
|
|
1345
|
-
}
|
|
1346
|
-
return null;
|
|
1347
|
-
}
|
|
1348
|
-
function shouldResetBranch(runManager, repo, issueNumber, currentRunId) {
|
|
1349
|
-
const prior = runManager.getLatestFinishedByIssue(repo, issueNumber, currentRunId);
|
|
1350
|
-
if (!prior)
|
|
1351
|
-
return false;
|
|
1352
|
-
// Infrastructure errors — work is unreliable
|
|
1353
|
-
if (prior.status === 'error')
|
|
1354
|
-
return true;
|
|
1355
|
-
// Blocked with tainted block reason — coder couldn't produce working code
|
|
1356
|
-
if (prior.status === 'blocked' && prior.blockReason && TAINTED_BLOCK_REASONS.has(prior.blockReason))
|
|
1357
|
-
return true;
|
|
1358
|
-
return false;
|
|
1359
|
-
}
|
|
1360
598
|
//# sourceMappingURL=poller.js.map
|