port: sync from Mike/ralpi@c870efa1
This commit is contained in:
@@ -124,6 +124,9 @@ Key config fields in `execution`:
|
|||||||
- `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at
|
- `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at
|
||||||
loop startup via `selectLoopOptions`; review is asked FIRST, commit is
|
loop startup via `selectLoopOptions`; review is asked FIRST, commit is
|
||||||
mandated when review is on)
|
mandated when review is on)
|
||||||
|
- `inactivityTimeoutMs` — hang detection: if no agent session event arrives
|
||||||
|
within this window (e.g. a bash subprocess that never returns), the task is
|
||||||
|
aborted (agent abort + bash subprocess kill). `0` = disabled (default)
|
||||||
- `models` — slot-aware round-robin model list for parallel mode, with
|
- `models` — slot-aware round-robin model list for parallel mode, with
|
||||||
failover to the next model per task (only after exhausting same-model
|
failover to the next model per task (only after exhausting same-model
|
||||||
retries, see `maxSameModelAttempts`)
|
retries, see `maxSameModelAttempts`)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mikefreno/omp-ralpi",
|
"name": "@mikefreno/omp-ralpi",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"omp",
|
"omp",
|
||||||
|
|||||||
115
src/executor.ts
115
src/executor.ts
@@ -244,6 +244,13 @@ export async function runTask(
|
|||||||
/** Review feedback from a rejected review — injected when re-executing
|
/** Review feedback from a rejected review — injected when re-executing
|
||||||
* a task in review-gated mode so the agent knows what to fix. */
|
* a task in review-gated mode so the agent knows what to fix. */
|
||||||
reviewFeedback?: ReviewResult,
|
reviewFeedback?: ReviewResult,
|
||||||
|
/** Session JSONL from a prior interrupted run of this task. When set and
|
||||||
|
* readable, the agent session reopens it and continues from the prior
|
||||||
|
* conversation instead of starting fresh. */
|
||||||
|
resumeSessionFile?: string,
|
||||||
|
/** Called with the session file path as soon as the agent session is
|
||||||
|
* created, so the caller can persist it for a later resume. */
|
||||||
|
onSessionFile?: (sessionFile: string) => void,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
reflection?: Reflection;
|
reflection?: Reflection;
|
||||||
@@ -253,6 +260,12 @@ export async function runTask(
|
|||||||
outputPreview?: string;
|
outputPreview?: string;
|
||||||
commitMessages?: string[];
|
commitMessages?: string[];
|
||||||
commitSummary?: string;
|
commitSummary?: string;
|
||||||
|
/** Path to the JSONL session file backing this run (for resume). */
|
||||||
|
sessionFile?: string;
|
||||||
|
/** True when a resume was requested but the session could not be opened
|
||||||
|
* from the file — the caller should clear the stored session file so
|
||||||
|
* retries start fresh. */
|
||||||
|
resumeFailed?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
@@ -384,6 +397,9 @@ export async function runTask(
|
|||||||
config.thinkingLevel,
|
config.thinkingLevel,
|
||||||
false, // noSkills — task sessions need skills
|
false, // noSkills — task sessions need skills
|
||||||
ctx.modelRegistry,
|
ctx.modelRegistry,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
|
resumeSessionFile,
|
||||||
|
onSessionFile,
|
||||||
);
|
);
|
||||||
|
|
||||||
const durationMs = Date.now() - startMs;
|
const durationMs = Date.now() - startMs;
|
||||||
@@ -408,6 +424,8 @@ export async function runTask(
|
|||||||
success: false,
|
success: false,
|
||||||
error: output.error,
|
error: output.error,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
sessionFile: output.sessionFile,
|
||||||
|
resumeFailed: output.resumeFailed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +456,7 @@ export async function runTask(
|
|||||||
outputPreview,
|
outputPreview,
|
||||||
commitMessages,
|
commitMessages,
|
||||||
commitSummary,
|
commitSummary,
|
||||||
|
sessionFile: output.sessionFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +843,12 @@ async function executeTask(
|
|||||||
: null;
|
: null;
|
||||||
const worktreeDir = wt?.dir ?? projectDir;
|
const worktreeDir = wt?.dir ?? projectDir;
|
||||||
|
|
||||||
|
// Session file from a prior interrupted run of this task. The first
|
||||||
|
// attempt reopens it so the agent continues with its prior conversation
|
||||||
|
// (tool calls, findings) instead of restarting from scratch; failover
|
||||||
|
// retries start fresh.
|
||||||
|
const resumeSessionFile = progress.getSessionFile(task.id);
|
||||||
|
|
||||||
while (modelAttempt < maxModelAttempts) {
|
while (modelAttempt < maxModelAttempts) {
|
||||||
// Model advancement happens in the cycling branch below (not here) so a
|
// Model advancement happens in the cycling branch below (not here) so a
|
||||||
// same-model retry `continue` doesn't re-advance and accidentally swap
|
// same-model retry `continue` doesn't re-advance and accidentally swap
|
||||||
@@ -884,6 +909,11 @@ async function executeTask(
|
|||||||
currentModel,
|
currentModel,
|
||||||
batchRender,
|
batchRender,
|
||||||
priorReview,
|
priorReview,
|
||||||
|
modelAttempt === 0 && sameModelAttempt === 0
|
||||||
|
? resumeSessionFile
|
||||||
|
: undefined,
|
||||||
|
(sessionFile) =>
|
||||||
|
progress.setSessionFile(task.id, sessionFile),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -935,6 +965,10 @@ async function executeTask(
|
|||||||
// A FAILED range computation (broken/stale base ref, git error) is
|
// A FAILED range computation (broken/stale base ref, git error) is
|
||||||
// logged as a distinct warning and is never treated as a clean,
|
// logged as a distinct warning and is never treated as a clean,
|
||||||
// verified task — only a GENUINE "no changes" skips review.
|
// verified task — only a GENUINE "no changes" skips review.
|
||||||
|
// Accumulated rejected reviews from earlier passes — injected
|
||||||
|
// into the next review prompt so the reviewer sees prior
|
||||||
|
// findings and can verify they were addressed.
|
||||||
|
const priorReviews: ReviewResult[] = [];
|
||||||
while (true) {
|
while (true) {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
@@ -972,16 +1006,17 @@ async function executeTask(
|
|||||||
reviewInfo.hash,
|
reviewInfo.hash,
|
||||||
reviewInfo.subject,
|
reviewInfo.subject,
|
||||||
reviewInfo.diff,
|
reviewInfo.diff,
|
||||||
{
|
{
|
||||||
projectContext: config.prompts.projectContext,
|
projectContext: config.prompts.projectContext,
|
||||||
focus: config.prompts.reviewFocus,
|
focus: config.prompts.reviewFocus,
|
||||||
diffOptions: {
|
priorReviews,
|
||||||
extraPatterns: compileIgnorePatterns(
|
diffOptions: {
|
||||||
config.review.extraIgnorePatterns,
|
extraPatterns: compileIgnorePatterns(
|
||||||
),
|
config.review.extraIgnorePatterns,
|
||||||
ignorePaths: config.review.ignorePaths,
|
),
|
||||||
},
|
ignorePaths: config.review.ignorePaths,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const reviewModel = resolveFollowUpModel(
|
const reviewModel = resolveFollowUpModel(
|
||||||
@@ -1003,6 +1038,7 @@ async function executeTask(
|
|||||||
`review-${task.id}`,
|
`review-${task.id}`,
|
||||||
config.execution.reviewTimeoutMs,
|
config.execution.reviewTimeoutMs,
|
||||||
reviewModels,
|
reviewModels,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!reviewResult.success) {
|
if (!reviewResult.success) {
|
||||||
@@ -1096,6 +1132,9 @@ async function executeTask(
|
|||||||
break; // changes already committed — merge proceeds
|
break; // changes already committed — merge proceeds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate the rejected review so the next review pass
|
||||||
|
// sees prior findings and can verify they were addressed.
|
||||||
|
if (review) priorReviews.push(review);
|
||||||
attempt++;
|
attempt++;
|
||||||
reviewRetries++;
|
reviewRetries++;
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
@@ -1292,6 +1331,7 @@ async function executeTask(
|
|||||||
finalCommitSummary,
|
finalCommitSummary,
|
||||||
finalReview,
|
finalReview,
|
||||||
reviewRetries,
|
reviewRetries,
|
||||||
|
result.sessionFile,
|
||||||
);
|
);
|
||||||
// Auto-update the PRD source file checkbox
|
// Auto-update the PRD source file checkbox
|
||||||
try {
|
try {
|
||||||
@@ -1305,6 +1345,12 @@ async function executeTask(
|
|||||||
|
|
||||||
// Agent session failed (provider error).
|
// Agent session failed (provider error).
|
||||||
// Pi's built-in in-call retry already exhausted for this attempt.
|
// Pi's built-in in-call retry already exhausted for this attempt.
|
||||||
|
// A resumed session that couldn't be opened (corrupt/missing
|
||||||
|
// JSONL) must not be retried — forget it so later attempts (and
|
||||||
|
// future resumes) start fresh.
|
||||||
|
if (result.resumeFailed) {
|
||||||
|
progress.setSessionFile(task.id, undefined);
|
||||||
|
}
|
||||||
// Reattempt on the SAME model a few more times before cycling — a
|
// Reattempt on the SAME model a few more times before cycling — a
|
||||||
// transient outage can outlast pi's per-prompt backoff window.
|
// transient outage can outlast pi's per-prompt backoff window.
|
||||||
sameModelAttempt++;
|
sameModelAttempt++;
|
||||||
@@ -1437,6 +1483,8 @@ async function runFollowUpSession(
|
|||||||
widgetKeySuffix: string,
|
widgetKeySuffix: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
models: unknown[],
|
models: unknown[],
|
||||||
|
/** Inactivity timeout for the session (see runAgentSession). */
|
||||||
|
inactivityTimeoutMs = 0,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
result: Awaited<ReturnType<typeof runAgentSession>>;
|
result: Awaited<ReturnType<typeof runAgentSession>>;
|
||||||
toolCalls: ToolCallEntry[];
|
toolCalls: ToolCallEntry[];
|
||||||
@@ -1535,6 +1583,7 @@ async function runFollowUpSession(
|
|||||||
config.thinkingLevel,
|
config.thinkingLevel,
|
||||||
false, // noSkills=false — follow-up sessions load skills too
|
false, // noSkills=false — follow-up sessions load skills too
|
||||||
ctx.modelRegistry,
|
ctx.modelRegistry,
|
||||||
|
inactivityTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) break;
|
if (result.success) break;
|
||||||
@@ -1681,6 +1730,7 @@ async function runCommitSession(
|
|||||||
`commit-${task.id}`,
|
`commit-${task.id}`,
|
||||||
config.execution.commitTimeoutMs,
|
config.execution.commitTimeoutMs,
|
||||||
commitModels,
|
commitModels,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (commitResult.success) {
|
if (commitResult.success) {
|
||||||
@@ -1778,6 +1828,52 @@ async function resolveConflictsSession(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge failed but produced no unmerged paths — no real conflicts to
|
||||||
|
// resolve. This can happen when the branch tip was already merged by the
|
||||||
|
// first attempt (mergeWorktree) before it aborted, or when the merge
|
||||||
|
// fails for a non-conflict reason (dirty index, stale ref). Don't spawn
|
||||||
|
// an agent session for zero conflicts — abort or complete and finish.
|
||||||
|
if (attempt.conflicts.length === 0) {
|
||||||
|
// Try to complete whatever merge state exists; if there's nothing to
|
||||||
|
// commit, abort to leave the working tree clean.
|
||||||
|
if (completeMerge(projectDir)) {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`✓ conflicts auto-resolved for ${task.id} · ${task.title}`,
|
||||||
|
);
|
||||||
|
removeWorktree(projectDir, worktree);
|
||||||
|
progress.markCompleted(
|
||||||
|
task.id,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "completed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abortMerge(projectDir);
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ conflict resolution for ${task.id} · ${task.title} — merge produced no conflicts but could not be completed`,
|
||||||
|
);
|
||||||
|
progress.markFailed(
|
||||||
|
task.id,
|
||||||
|
`Merge of ${branch} produced no conflicts but could not be completed`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Conflicts exist — spawn a resolution agent session.
|
// Conflicts exist — spawn a resolution agent session.
|
||||||
const prompt = buildConflictResolutionPrompt(
|
const prompt = buildConflictResolutionPrompt(
|
||||||
task,
|
task,
|
||||||
@@ -1807,6 +1903,7 @@ async function resolveConflictsSession(
|
|||||||
`resolve-${task.id}`,
|
`resolve-${task.id}`,
|
||||||
config.execution.commitTimeoutMs,
|
config.execution.commitTimeoutMs,
|
||||||
models,
|
models,
|
||||||
|
config.execution.inactivityTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -185,11 +185,13 @@ export class ProgressTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a task as in progress */
|
/** Mark a task as in progress */
|
||||||
markInProgress(taskId: string): void {
|
markInProgress(taskId: string, sessionFile?: string): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].status = "in_progress";
|
prd.tasks[taskId].status = "in_progress";
|
||||||
prd.tasks[taskId].startedAt = new Date().toISOString();
|
prd.tasks[taskId].startedAt = new Date().toISOString();
|
||||||
|
if (sessionFile !== undefined)
|
||||||
|
prd.tasks[taskId].sessionFile = sessionFile;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +206,7 @@ export class ProgressTracker {
|
|||||||
commitSummary?: string,
|
commitSummary?: string,
|
||||||
review?: ReviewResult,
|
review?: ReviewResult,
|
||||||
reviewRetries?: number,
|
reviewRetries?: number,
|
||||||
|
sessionFile?: string,
|
||||||
): void {
|
): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
@@ -218,6 +221,7 @@ export class ProgressTracker {
|
|||||||
if (review) prd.tasks[taskId].review = review;
|
if (review) prd.tasks[taskId].review = review;
|
||||||
if (reviewRetries !== undefined)
|
if (reviewRetries !== undefined)
|
||||||
prd.tasks[taskId].reviewRetries = reviewRetries;
|
prd.tasks[taskId].reviewRetries = reviewRetries;
|
||||||
|
if (sessionFile !== undefined) prd.tasks[taskId].sessionFile = sessionFile;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +240,22 @@ export class ProgressTracker {
|
|||||||
return prd.tasks[taskId]?.status ?? "pending";
|
return prd.tasks[taskId]?.status ?? "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the persisted session file path for a task (for resume), if any. */
|
||||||
|
getSessionFile(taskId: string): string | undefined {
|
||||||
|
const prd = this.getPRD();
|
||||||
|
return prd.tasks[taskId]?.sessionFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the session file path for a task without changing its status.
|
||||||
|
* Called as soon as an agent session is created so an interrupted run
|
||||||
|
* can be resumed from the JSONL history. Pass undefined to clear. */
|
||||||
|
setSessionFile(taskId: string, sessionFile: string | undefined): void {
|
||||||
|
const prd = this.getPRD();
|
||||||
|
this.ensureTask(prd, taskId);
|
||||||
|
prd.tasks[taskId].sessionFile = sessionFile;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
/** Get IDs of all completed tasks */
|
/** Get IDs of all completed tasks */
|
||||||
getCompletedTaskIds(): string[] {
|
getCompletedTaskIds(): string[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export interface ReviewPromptOptions {
|
|||||||
focus?: string;
|
focus?: string;
|
||||||
/** Noise-filter overrides (config.review.*). */
|
/** Noise-filter overrides (config.review.*). */
|
||||||
diffOptions?: DiffOptions;
|
diffOptions?: DiffOptions;
|
||||||
|
/** Prior review results from earlier passes in a review-gated loop.
|
||||||
|
* Each rejected review is injected into the next review prompt so the
|
||||||
|
* reviewer can verify prior findings were addressed and catch new
|
||||||
|
* regressions introduced by the fix attempt — instead of re-reviewing
|
||||||
|
* from scratch. */
|
||||||
|
priorReviews?: ReviewResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
||||||
@@ -258,8 +264,12 @@ export function buildReviewPrompt(
|
|||||||
parts.push(renderDiffSection(summary, filtered, "### Diff"));
|
parts.push(renderDiffSection(summary, filtered, "### Diff"));
|
||||||
parts.push("");
|
parts.push("");
|
||||||
|
|
||||||
// ── Custom Review Focus ──
|
// ── Prior Review History (review-gated re-review) ──
|
||||||
|
|
||||||
|
parts.push(renderPriorReviews(opts.priorReviews ?? []));
|
||||||
|
if (opts.priorReviews && opts.priorReviews.length > 0) parts.push("");
|
||||||
|
|
||||||
|
// ── Custom Review Focus ──
|
||||||
if (opts.focus) {
|
if (opts.focus) {
|
||||||
parts.push("## Custom Review Focus");
|
parts.push("## Custom Review Focus");
|
||||||
parts.push(opts.focus);
|
parts.push(opts.focus);
|
||||||
@@ -360,7 +370,11 @@ export function buildReviewPromptUncommitted(
|
|||||||
parts.push(
|
parts.push(
|
||||||
renderDiffSection(summary, filtered, "### Current Tracked Diff (git diff)"),
|
renderDiffSection(summary, filtered, "### Current Tracked Diff (git diff)"),
|
||||||
);
|
);
|
||||||
parts.push("");
|
|
||||||
|
// ── Prior Review History (review-gated re-review) ──
|
||||||
|
|
||||||
|
parts.push(renderPriorReviews(opts.priorReviews ?? []));
|
||||||
|
if (opts.priorReviews && opts.priorReviews.length > 0) parts.push("");
|
||||||
|
|
||||||
// ── Custom Review Focus ──
|
// ── Custom Review Focus ──
|
||||||
|
|
||||||
@@ -469,6 +483,45 @@ function renderDiffSection(
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a "Prior Review History" section from earlier rejected reviews.
|
||||||
|
* Returns an empty string when there are no prior reviews so callers omit
|
||||||
|
* the section entirely.
|
||||||
|
*
|
||||||
|
* Each prior review's verdict, summary, and findings are listed so the
|
||||||
|
* reviewer can verify the developer addressed them and watch for new
|
||||||
|
* regressions — instead of re-reviewing from scratch on each pass.
|
||||||
|
*/
|
||||||
|
function renderPriorReviews(priorReviews: ReviewResult[]): string {
|
||||||
|
if (priorReviews.length === 0) return "";
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("## Prior Review History");
|
||||||
|
lines.push(
|
||||||
|
"Previous review pass(es) rejected this task. Verify each finding was",
|
||||||
|
"addressed in the current diff and watch for new regressions:",
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
for (let i = 0; i < priorReviews.length; i++) {
|
||||||
|
const r = priorReviews[i];
|
||||||
|
if (!r) continue;
|
||||||
|
lines.push(`### Review ${i + 1} — ${r.verdict.toUpperCase()}`);
|
||||||
|
lines.push(`Summary: ${r.summary}`);
|
||||||
|
if (r.findings.length > 0) {
|
||||||
|
lines.push("Findings:");
|
||||||
|
for (const f of r.findings) {
|
||||||
|
const loc = f.file
|
||||||
|
? f.line
|
||||||
|
? ` (${f.file}:${f.line})`
|
||||||
|
: ` (${f.file})`
|
||||||
|
: "";
|
||||||
|
lines.push(`- [${f.severity}]${loc} ${f.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function reviewInstructions(): string[] {
|
function reviewInstructions(): string[] {
|
||||||
return [
|
return [
|
||||||
"- **Correctness**: Does the implementation fulfill the task requirements?",
|
"- **Correctness**: Does the implementation fulfill the task requirements?",
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -161,6 +161,10 @@ export interface TaskProgressInfo {
|
|||||||
commitSummary?: string;
|
commitSummary?: string;
|
||||||
/** Number of review-fix re-execution attempts made (review-gated mode) */
|
/** Number of review-fix re-execution attempts made (review-gated mode) */
|
||||||
reviewRetries?: number;
|
reviewRetries?: number;
|
||||||
|
/** Path to the JSONL session file backing this task's agent session,
|
||||||
|
* persisted so a resume can reopen it and continue where the
|
||||||
|
* interrupted session left off. */
|
||||||
|
sessionFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgressState {
|
export interface ProgressState {
|
||||||
@@ -205,6 +209,11 @@ export interface RalpiConfig {
|
|||||||
execution: {
|
execution: {
|
||||||
/** Task execution timeout in milliseconds */
|
/** Task execution timeout in milliseconds */
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
/** Inactivity timeout in milliseconds — if no agent session event
|
||||||
|
* arrives within this window, the task is considered hung (e.g. a
|
||||||
|
* bash subprocess that never returns) and the session is aborted.
|
||||||
|
* 0 = disabled. */
|
||||||
|
inactivityTimeoutMs: number;
|
||||||
/** Maximum parallel tasks (0 = unlimited) */
|
/** Maximum parallel tasks (0 = unlimited) */
|
||||||
maxParallel: number;
|
maxParallel: number;
|
||||||
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||||
@@ -301,6 +310,7 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
|||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
|
inactivityTimeoutMs: 0, // 0 = disabled (no inactivity hang detection)
|
||||||
maxParallel: 3,
|
maxParallel: 3,
|
||||||
models: [],
|
models: [],
|
||||||
autoCommit: true,
|
autoCommit: true,
|
||||||
|
|||||||
88
src/utils.ts
88
src/utils.ts
@@ -611,6 +611,18 @@ export async function runAgentSession(
|
|||||||
* rate-limit normalization) are available. When omitted, the SDK creates
|
* rate-limit normalization) are available. When omitted, the SDK creates
|
||||||
* a fresh registry from models.json only — extension providers are lost. */
|
* a fresh registry from models.json only — extension providers are lost. */
|
||||||
modelRegistry?: ModelRegistry,
|
modelRegistry?: ModelRegistry,
|
||||||
|
/** Inactivity timeout in milliseconds — if no agent session event arrives
|
||||||
|
* within this window, the task is considered hung (e.g. a bash subprocess
|
||||||
|
* that never returns) and the session is aborted. 0 = disabled. */
|
||||||
|
inactivityTimeoutMs = 0,
|
||||||
|
/** Existing session JSONL file to resume. The session reopens the file and
|
||||||
|
* appends to it, so the agent sees the full prior conversation and can
|
||||||
|
* continue rather than redo prior tool calls. When the file is missing,
|
||||||
|
* a fresh session is created instead (with a warning). */
|
||||||
|
resumeSessionFile?: string,
|
||||||
|
/** Called with the session file path as soon as the session is created, so
|
||||||
|
* callers can persist it for resume before the session completes. */
|
||||||
|
onSessionFile?: (sessionFile: string) => void,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -618,6 +630,13 @@ export async function runAgentSession(
|
|||||||
toolUsage: ToolUsage;
|
toolUsage: ToolUsage;
|
||||||
stopReason?: string;
|
stopReason?: string;
|
||||||
events: AgentSessionEvent[];
|
events: AgentSessionEvent[];
|
||||||
|
/** Path to the JSONL session file backing this session (set once the
|
||||||
|
* session is created; enables resume). */
|
||||||
|
sessionFile?: string;
|
||||||
|
/** True when a resume was requested but the session could not be created
|
||||||
|
* from the file (corrupt/unreadable JSONL). Callers should clear the
|
||||||
|
* stored session file so retries start fresh. */
|
||||||
|
resumeFailed?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const toolUsage: ToolUsage = {
|
const toolUsage: ToolUsage = {
|
||||||
read: 0,
|
read: 0,
|
||||||
@@ -638,12 +657,45 @@ export async function runAgentSession(
|
|||||||
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
let sessionFile: string | undefined;
|
||||||
|
let sessionCreated = false;
|
||||||
|
// Inactivity watchdog: aborts the session when no events arrive within
|
||||||
|
// inactivityTimeoutMs. The SDK emits an event for every tool start/end/
|
||||||
|
// update and message start/end, so silence means the agent is stuck
|
||||||
|
// (typically a hung bash subprocess producing no output).
|
||||||
|
let inactivityInterval: NodeJS.Timeout | null = null;
|
||||||
|
let inactivityAborted = false;
|
||||||
|
let lastEventTime = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Loop sessions load the full normal omp context: extensions (so all
|
// Loop sessions load the full normal omp context: extensions (so all
|
||||||
// extension-provided tools register) and project context (AGENTS.md).
|
// extension-provided tools register) and project context (AGENTS.md).
|
||||||
|
// Persist sessions under the ralpi project's `.ralpi/sessions/` so they
|
||||||
|
// survive worktree removal and are findable from the main repo on resume.
|
||||||
|
// Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up
|
||||||
|
// from the agent's cwd always finds the main project's `.ralpi` first.
|
||||||
|
const ralpiDir = findRalpiDir(cwd);
|
||||||
|
const sessionDir = ralpiDir
|
||||||
|
? path.join(ralpiDir, ".ralpi", "sessions")
|
||||||
|
: path.join(cwd, ".ralpi", "sessions");
|
||||||
|
|
||||||
|
let sessionManager: SessionManager;
|
||||||
|
if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {
|
||||||
|
sessionManager = await SessionManager.open(resumeSessionFile, sessionDir, undefined, {
|
||||||
|
initialCwd: cwd,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (resumeSessionFile) {
|
||||||
|
console.warn(
|
||||||
|
`[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sessionManager = SessionManager.create(cwd, sessionDir);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await createAgentSession({
|
const result = await createAgentSession({
|
||||||
cwd,
|
cwd,
|
||||||
sessionManager: SessionManager.inMemory(cwd),
|
sessionManager,
|
||||||
settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),
|
settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),
|
||||||
// Loop sessions intentionally load extensions (no disableExtensionDiscovery),
|
// Loop sessions intentionally load extensions (no disableExtensionDiscovery),
|
||||||
// plus skills and project context via default discovery.
|
// plus skills and project context via default discovery.
|
||||||
@@ -655,8 +707,12 @@ export async function runAgentSession(
|
|||||||
modelRegistry,
|
modelRegistry,
|
||||||
agentRegistry: new AgentRegistry(),
|
agentRegistry: new AgentRegistry(),
|
||||||
});
|
});
|
||||||
|
sessionCreated = true;
|
||||||
sessionRef.session = result.session;
|
sessionRef.session = result.session;
|
||||||
|
|
||||||
|
sessionFile = result.session.sessionFile;
|
||||||
|
if (sessionFile) onSessionFile?.(sessionFile);
|
||||||
|
|
||||||
// Wire external abort signal
|
// Wire external abort signal
|
||||||
const abortHandler = () => result.session.agent.abort();
|
const abortHandler = () => result.session.agent.abort();
|
||||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||||
@@ -664,8 +720,26 @@ export async function runAgentSession(
|
|||||||
let finalText = "";
|
let finalText = "";
|
||||||
let errorMessage: string | undefined;
|
let errorMessage: string | undefined;
|
||||||
let stopReason: string | undefined;
|
let stopReason: string | undefined;
|
||||||
|
lastEventTime = Date.now();
|
||||||
|
|
||||||
|
// Inactivity watchdog: check the silence window on an interval and abort
|
||||||
|
// (plus kill any hung bash subprocess) when it is exceeded.
|
||||||
|
if (inactivityTimeoutMs > 0) {
|
||||||
|
const intervalMs = Math.min(inactivityTimeoutMs, 5000);
|
||||||
|
inactivityInterval = setInterval(() => {
|
||||||
|
if (!sessionRef.session) return;
|
||||||
|
if (Date.now() - lastEventTime <= inactivityTimeoutMs) return;
|
||||||
|
inactivityAborted = true;
|
||||||
|
sessionRef.session.agent.abort();
|
||||||
|
sessionRef.session.abortBash();
|
||||||
|
errorMessage = `Task aborted: inactivity timeout (no events for ${Math.round(inactivityTimeoutMs / 1000)}s)`;
|
||||||
|
if (inactivityInterval) clearInterval(inactivityInterval);
|
||||||
|
inactivityInterval = null;
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribe = result.session.subscribe((event) => {
|
const unsubscribe = result.session.subscribe((event) => {
|
||||||
|
lastEventTime = Date.now();
|
||||||
onEvent?.(event);
|
onEvent?.(event);
|
||||||
|
|
||||||
if (event.type === "message_end") {
|
if (event.type === "message_end") {
|
||||||
@@ -677,7 +751,10 @@ export async function runAgentSession(
|
|||||||
};
|
};
|
||||||
if (message.role !== "assistant") return;
|
if (message.role !== "assistant") return;
|
||||||
if (message.stopReason) stopReason = message.stopReason;
|
if (message.stopReason) stopReason = message.stopReason;
|
||||||
if (message.errorMessage) errorMessage = message.errorMessage;
|
// Keep the inactivity-timeout message: the abort's own errorMessage
|
||||||
|
// would otherwise clobber the (more useful) hang explanation.
|
||||||
|
if (message.errorMessage && !inactivityAborted)
|
||||||
|
errorMessage = message.errorMessage;
|
||||||
const text = extractAssistantText(message.content);
|
const text = extractAssistantText(message.content);
|
||||||
if (text) finalText = text;
|
if (text) finalText = text;
|
||||||
}
|
}
|
||||||
@@ -710,6 +787,7 @@ export async function runAgentSession(
|
|||||||
toolUsage,
|
toolUsage,
|
||||||
stopReason,
|
stopReason,
|
||||||
events: [], // streamed to file
|
events: [], // streamed to file
|
||||||
|
sessionFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +797,7 @@ export async function runAgentSession(
|
|||||||
toolUsage,
|
toolUsage,
|
||||||
stopReason,
|
stopReason,
|
||||||
events: [],
|
events: [],
|
||||||
|
sessionFile,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
@@ -728,9 +807,14 @@ export async function runAgentSession(
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
toolUsage,
|
toolUsage,
|
||||||
events: [],
|
events: [],
|
||||||
|
sessionFile,
|
||||||
|
// A requested resume that failed to open (corrupt/unreadable file)
|
||||||
|
// should not be retried — callers clear the stored file and go fresh.
|
||||||
|
resumeFailed: resumeSessionFile !== undefined && !sessionCreated,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
sessionRef.session?.dispose();
|
sessionRef.session?.dispose();
|
||||||
|
if (inactivityInterval) clearInterval(inactivityInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user