@h-rig/server 0.0.6-alpha.2 → 0.0.6-alpha.4

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/dist/src/index.js CHANGED
@@ -2970,7 +2970,10 @@ function createPiIssueAnalyzer(input = {}) {
2970
2970
  const runCommand = input.runCommand ?? createDefaultPiIssueAnalysisCommandRunner();
2971
2971
  return async ({ prompt }) => {
2972
2972
  const args = ["--print", "--mode", "json", "--no-session"];
2973
- const model = input.model?.trim() || process.env.RIG_ISSUE_ANALYSIS_MODEL?.trim() || "openai-codex/gpt-5.5";
2973
+ const provider = input.provider?.trim() || process.env.RIG_ISSUE_ANALYSIS_PROVIDER?.trim() || process.env.RIG_PI_PROVIDER?.trim();
2974
+ const model = input.model?.trim() || process.env.RIG_ISSUE_ANALYSIS_MODEL?.trim() || process.env.RIG_PI_MODEL?.trim() || "openai-codex/gpt-5.5";
2975
+ if (provider)
2976
+ args.push("--provider", provider);
2974
2977
  if (model)
2975
2978
  args.push("--model", model);
2976
2979
  args.push(prompt);
@@ -4380,11 +4383,23 @@ function assertNoActiveRunForTask(projectRoot, taskId, newRunId) {
4380
4383
  return;
4381
4384
  throw new Error(`Task ${taskId} already has an active Rig run: ${existing.runId}`);
4382
4385
  }
4386
+ async function resolveSourceTaskForRun(projectRoot, taskId, readTasks) {
4387
+ const fromReader = (await readTasks(projectRoot)).find((task) => task.id === taskId) ?? null;
4388
+ if (fromReader)
4389
+ return fromReader;
4390
+ const projected = readTaskProjection(projectRoot)?.tasks.find((task) => String(task.id) === taskId) ?? null;
4391
+ if (projected)
4392
+ return projected;
4393
+ if (readTasks !== readWorkspaceTasks) {
4394
+ return (await readWorkspaceTasks(projectRoot)).find((task) => task.id === taskId) ?? null;
4395
+ }
4396
+ return null;
4397
+ }
4383
4398
  async function createRunRecord(projectRoot, input, readTasks = readWorkspaceTasks) {
4384
4399
  if ("taskId" in input && input.taskId) {
4385
4400
  assertNoActiveRunForTask(projectRoot, input.taskId, input.runId);
4386
4401
  }
4387
- const sourceTask = "taskId" in input && input.taskId ? (await readTasks(projectRoot)).find((task) => task.id === input.taskId) ?? null : null;
4402
+ const sourceTask = "taskId" in input && input.taskId ? await resolveSourceTaskForRun(projectRoot, input.taskId, readTasks) : null;
4388
4403
  const taskTitle = sourceTask?.title ?? ("taskId" in input && input.taskId ? input.taskId : null);
4389
4404
  const runDir = resolveAuthorityRunDir3(projectRoot, input.runId);
4390
4405
  const runRecord = {
@@ -4456,6 +4471,7 @@ async function startLocalRun(state, runId, options) {
4456
4471
  throw new Error(`Run not found: ${runId}`);
4457
4472
  }
4458
4473
  const startedAt = new Date().toISOString();
4474
+ const resumeMode = options?.resume === true;
4459
4475
  state.runProcesses.set(runId, {
4460
4476
  runId,
4461
4477
  child: null,
@@ -4472,9 +4488,9 @@ async function startLocalRun(state, runId, options) {
4472
4488
  summary: run.title
4473
4489
  });
4474
4490
  appendRunLogEntry(state.projectRoot, runId, {
4475
- id: `log:${runId}:prepare`,
4476
- title: "Rig task run starting",
4477
- detail: run.taskId ?? run.title,
4491
+ id: `log:${runId}:${resumeMode ? "resume" : "prepare"}`,
4492
+ title: resumeMode ? "Rig task run resuming" : "Rig task run starting",
4493
+ detail: resumeMode ? `Resuming ${run.taskId ?? run.title ?? runId} after server restart or operator resume.` : run.taskId ?? run.title,
4478
4494
  tone: "info",
4479
4495
  status: "preparing",
4480
4496
  createdAt: startedAt
@@ -4552,7 +4568,15 @@ async function startLocalRun(state, runId, options) {
4552
4568
  RIG_SERVER_INTERNAL_EXEC: "1",
4553
4569
  ...serverUrl ? { RIG_SERVER_URL: serverUrl } : {},
4554
4570
  ...bridgeAuthToken ? { RIG_AUTH_TOKEN: bridgeAuthToken } : {},
4555
- ...bridgeGitHubToken ? { RIG_GITHUB_TOKEN: bridgeGitHubToken } : {}
4571
+ ...bridgeGitHubToken ? {
4572
+ RIG_GITHUB_TOKEN: bridgeGitHubToken,
4573
+ GITHUB_TOKEN: bridgeGitHubToken,
4574
+ GH_TOKEN: bridgeGitHubToken
4575
+ } : {},
4576
+ ...resumeMode ? {
4577
+ RIG_RUN_RESUME: "1",
4578
+ RIG_RUNTIME_ARTIFACT_CLEANUP: "preserve"
4579
+ } : {}
4556
4580
  },
4557
4581
  stdio: ["ignore", "pipe", "pipe"]
4558
4582
  });
@@ -4724,7 +4748,7 @@ async function resumeRunRecord(state, input) {
4724
4748
  if (run.status === "completed") {
4725
4749
  throw new Error("Completed runs cannot be resumed.");
4726
4750
  }
4727
- await startLocalRun(state, input.runId, { promptOverride: input.promptOverride ?? null });
4751
+ await startLocalRun(state, input.runId, { promptOverride: input.promptOverride ?? null, resume: input.restart !== true });
4728
4752
  }
4729
4753
  function appendRunMessage(projectRoot, input) {
4730
4754
  const run = readAuthorityRun4(projectRoot, input.runId);
@@ -4808,34 +4832,12 @@ function removeTaskIdsFromQueueState(projectRoot, taskIds) {
4808
4832
  writeQueueState(projectRoot, next);
4809
4833
  return next;
4810
4834
  }
4811
- var ORPHANABLE_LOCAL_RUN_STATUSES = new Set(["preparing", "running"]);
4812
- function reconcileOrphanedLocalRuns(state, runs, nowIso2) {
4813
- let changed = false;
4814
- for (const run of runs) {
4835
+ var RESUMABLE_LOCAL_RUN_STATUSES = new Set(["created", "preparing", "running", "validating", "reviewing"]);
4836
+ function collectResumableLocalRuns(state, runs) {
4837
+ return runs.filter((run) => {
4815
4838
  const status = normalizeString(run.status)?.toLowerCase() ?? "";
4816
- const serverPid = run.serverPid;
4817
- const wasStartedByRigServer = typeof serverPid === "number" || typeof serverPid === "string";
4818
- if (run.mode !== "local" || !wasStartedByRigServer || !ORPHANABLE_LOCAL_RUN_STATUSES.has(status) || state.runProcesses.has(run.runId)) {
4819
- continue;
4820
- }
4821
- const detail = "Recovered stale local run after Rig server restart; no live child process was attached to this server instance.";
4822
- patchRunRecord(state.projectRoot, run.runId, {
4823
- status: "failed",
4824
- completedAt: run.completedAt ?? nowIso2,
4825
- updatedAt: nowIso2,
4826
- errorText: detail
4827
- });
4828
- appendRunLogEntry(state.projectRoot, run.runId, {
4829
- id: `log:${run.runId}:stale-local-run`,
4830
- title: "Run marked stale after server restart",
4831
- detail,
4832
- tone: "error",
4833
- status: "failed",
4834
- createdAt: nowIso2
4835
- });
4836
- changed = true;
4837
- }
4838
- return changed;
4839
+ return run.mode === "local" && RESUMABLE_LOCAL_RUN_STATUSES.has(status) && !state.runProcesses.has(run.runId);
4840
+ });
4839
4841
  }
4840
4842
  async function reconcileScheduler(state, reason) {
4841
4843
  if (state.scheduler.reconciling) {
@@ -4850,7 +4852,20 @@ async function reconcileScheduler(state, reason) {
4850
4852
  const queue = readQueueState(state.projectRoot);
4851
4853
  const tasks = await state.snapshotService.getWorkspaceTasks();
4852
4854
  let runs = listAuthorityRuns4(state.projectRoot);
4853
- let changed = reconcileOrphanedLocalRuns(state, runs, new Date().toISOString());
4855
+ let changed = false;
4856
+ const resumableRuns = collectResumableLocalRuns(state, runs);
4857
+ for (const run of resumableRuns) {
4858
+ appendRunLogEntry(state.projectRoot, run.runId, {
4859
+ id: `log:${run.runId}:auto-resume:${Date.now()}`,
4860
+ title: "Run auto-resume scheduled",
4861
+ detail: `Rig server recovered nonterminal run ${run.runId} after ${reason}; resuming the same lifecycle instead of restarting it.`,
4862
+ tone: "info",
4863
+ status: "preparing",
4864
+ createdAt: new Date().toISOString()
4865
+ });
4866
+ await startLocalRun(state, run.runId, { resume: true });
4867
+ changed = true;
4868
+ }
4854
4869
  if (changed) {
4855
4870
  runs = listAuthorityRuns4(state.projectRoot);
4856
4871
  }
@@ -6128,6 +6143,27 @@ function filterWorkspaceTasks(projectRoot, tasks, searchParams) {
6128
6143
  }
6129
6144
  return filtered;
6130
6145
  }
6146
+ function issueAnalysisTargetFor(source) {
6147
+ if (!source)
6148
+ return null;
6149
+ const candidate = source;
6150
+ if (typeof candidate.updateTask !== "function")
6151
+ return null;
6152
+ return {
6153
+ ...typeof candidate.get === "function" ? { get: candidate.get.bind(candidate) } : {},
6154
+ updateTask: candidate.updateTask.bind(candidate),
6155
+ ...typeof candidate.addLabels === "function" ? { addLabels: candidate.addLabels.bind(candidate) } : {},
6156
+ ...typeof candidate.removeLabels === "function" ? { removeLabels: candidate.removeLabels.bind(candidate) } : {},
6157
+ ...typeof candidate.createIssue === "function" ? { createIssue: candidate.createIssue.bind(candidate) } : {}
6158
+ };
6159
+ }
6160
+ function uniqueStringList(value) {
6161
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
6162
+ return [...new Set(raw.map((entry) => String(entry).trim()).filter(Boolean))];
6163
+ }
6164
+ function taskRecordId(task) {
6165
+ return String(task.id ?? "");
6166
+ }
6131
6167
  function redactRemoteEndpoint(endpoint) {
6132
6168
  const { token, ...rest } = endpoint;
6133
6169
  return {
@@ -6470,6 +6506,67 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
6470
6506
  note: "GitHub issue lifecycle labels are created on demand by the configured task source when supported."
6471
6507
  });
6472
6508
  }
6509
+ if (url.pathname === "/api/workspace/issue-analysis/run" && req.method === "POST") {
6510
+ const body = await deps.readJsonBody(req);
6511
+ const ids = uniqueStringList(body.ids ?? body.id);
6512
+ const analyzeAll = deps.isTruthyQuery(String(body.all ?? ""));
6513
+ if (ids.length === 0 && !analyzeAll) {
6514
+ return deps.badRequest("ids is required unless all=true");
6515
+ }
6516
+ const ctx = await getCachedPluginHostContext(state.projectRoot);
6517
+ const [source] = ctx?.taskSourceRegistry.list() ?? [];
6518
+ const target = issueAnalysisTargetFor(source);
6519
+ if (!source || !target) {
6520
+ return deps.badRequest("Configured task source does not support issue-analysis writeback");
6521
+ }
6522
+ const allTasks = [...await source.list()];
6523
+ const issues = analyzeAll ? allTasks.slice(0, Math.max(1, Math.min(25, Number(body.limit ?? 25) || 25))) : (await Promise.all(ids.map(async (id) => {
6524
+ const cached = allTasks.find((task) => taskRecordId(task) === id);
6525
+ if (cached)
6526
+ return cached;
6527
+ return typeof source.get === "function" ? await source.get(id) : undefined;
6528
+ }))).filter((task) => Boolean(task));
6529
+ if (issues.length === 0) {
6530
+ return deps.jsonResponse({ ok: false, error: "No matching issues found for issue analysis", ids }, 404);
6531
+ }
6532
+ const config = ctx?.config && typeof ctx.config === "object" ? ctx.config : {};
6533
+ const issueAnalysis = config.issueAnalysis && typeof config.issueAnalysis === "object" ? config.issueAnalysis : {};
6534
+ const runtime = config.runtime && typeof config.runtime === "object" ? config.runtime : {};
6535
+ const model = normalizeString(issueAnalysis.model) ?? normalizeString(runtime.model);
6536
+ const service = createIssueAnalysisService({
6537
+ analyzer: createPiIssueAnalyzer({
6538
+ ...model ? { model } : {},
6539
+ env: { RIG_PROJECT_ROOT: state.projectRoot }
6540
+ }),
6541
+ writeBack: createIssueAnalysisWriteBack({ target })
6542
+ });
6543
+ const reason = normalizeString(body.reason) ?? "http-issue-analysis";
6544
+ let results;
6545
+ try {
6546
+ results = await service.analyze(issues, { reason, neighbors: ids.length > 0 ? issues : allTasks });
6547
+ } catch (error) {
6548
+ return deps.jsonResponse({
6549
+ ok: false,
6550
+ error: `Issue analysis failed: ${error instanceof Error ? error.message : String(error)}`,
6551
+ reason,
6552
+ ids: issues.map((issue) => issue.id)
6553
+ }, 502);
6554
+ }
6555
+ deps.snapshotService.invalidate("issue-analysis-http-run");
6556
+ await state.taskProjectionReconciler?.tick("issue-analysis-http-run").catch(() => {
6557
+ return;
6558
+ });
6559
+ deps.broadcastSnapshotInvalidation(state, "issue-analysis-http-run");
6560
+ return deps.jsonResponse({
6561
+ ok: true,
6562
+ reason,
6563
+ analyzed: results.map((entry) => ({
6564
+ id: entry.issue.id,
6565
+ title: entry.issue.title ?? null,
6566
+ result: entry.result
6567
+ }))
6568
+ });
6569
+ }
6473
6570
  if (url.pathname === "/api/server/status") {
6474
6571
  const config = buildProjectConfigStatus(state.projectRoot);
6475
6572
  const taskSource = await buildTaskSourceStatus(state, config);
@@ -7165,11 +7262,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
7165
7262
  const runId = normalizeString(body.runId);
7166
7263
  const createdAt = normalizeString(body.createdAt) ?? new Date().toISOString();
7167
7264
  const promptOverride = normalizeString(body.promptOverride);
7265
+ const restart = body.restart === true;
7168
7266
  if (!runId) {
7169
7267
  return deps.badRequest("runId is required");
7170
7268
  }
7171
7269
  try {
7172
- await deps.resumeRunRecord(state, { runId, createdAt, promptOverride });
7270
+ await deps.resumeRunRecord(state, { runId, createdAt, promptOverride, restart });
7173
7271
  deps.broadcastSnapshotInvalidation(state);
7174
7272
  return deps.jsonResponse({ ok: true, runId, createdAt });
7175
7273
  } catch (error) {
@@ -13079,6 +13177,7 @@ async function createRigServer(options, projectRoot = resolveProjectRoot()) {
13079
13177
  const server = Bun.serve({
13080
13178
  hostname: options.host,
13081
13179
  port: options.port,
13180
+ idleTimeout: Math.max(10, Math.min(255, Number.parseInt(process.env.RIG_SERVER_IDLE_TIMEOUT_SECONDS || "255", 10) || 255)),
13082
13181
  fetch: (req, server2) => createRigServerFetch2(state)(req, server2),
13083
13182
  websocket: {
13084
13183
  open(ws) {
@@ -469,6 +469,257 @@ async function refreshTaskProjection(projectRoot, input) {
469
469
  });
470
470
  }
471
471
 
472
+ // packages/server/src/server-helpers/issue-analysis.ts
473
+ import { execFile } from "child_process";
474
+ import { createHash } from "crypto";
475
+ function stableIssueHash(issue) {
476
+ const labels = Array.isArray(issue.labels) ? [...issue.labels].map(String).sort() : [];
477
+ const body = typeof issue.body === "string" ? issue.body : "";
478
+ const title = typeof issue.title === "string" ? issue.title : "";
479
+ return createHash("sha256").update(JSON.stringify({ id: issue.id, title, body, labels, deps: issue.deps, status: issue.status })).digest("hex");
480
+ }
481
+ function renderIssueAnalysisPrompt(input) {
482
+ const issue = input.issue;
483
+ const neighbors = input.neighbors ?? [];
484
+ return [
485
+ "You are Rig issue analysis running inside Pi.",
486
+ "Return JSON only with optional metadataPatch, labelsToAdd, labelsToRemove, and generatedIssues.",
487
+ "Preserve all human-authored issue body content. Only propose edits for Rig-owned metadata/status sections, labels, and generated issues.",
488
+ "Generated issues must be concrete, minimal follow-up tasks and will be labeled rig:generated by Rig.",
489
+ "",
490
+ "Issue:",
491
+ JSON.stringify({
492
+ id: issue.id,
493
+ title: issue.title,
494
+ body: issue.body,
495
+ labels: issue.labels,
496
+ deps: issue.deps,
497
+ status: issue.status
498
+ }, null, 2),
499
+ "",
500
+ "Neighbor tasks:",
501
+ JSON.stringify(neighbors.map((task) => ({ id: task.id, title: task.title, status: task.status, deps: task.deps })), null, 2)
502
+ ].join(`
503
+ `);
504
+ }
505
+ function isRecord(value) {
506
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
507
+ }
508
+ function stringArray(value) {
509
+ if (!Array.isArray(value))
510
+ return;
511
+ return value.map(String).filter((entry) => entry.trim().length > 0);
512
+ }
513
+ function generatedIssues(value) {
514
+ if (!Array.isArray(value))
515
+ return;
516
+ return value.flatMap((entry) => {
517
+ if (!isRecord(entry) || typeof entry.title !== "string")
518
+ return [];
519
+ return [{
520
+ title: entry.title,
521
+ body: typeof entry.body === "string" ? entry.body : "",
522
+ labels: stringArray(entry.labels) ?? [],
523
+ ...Array.isArray(entry.dependsOn) ? { dependsOn: entry.dependsOn.map(String) } : {}
524
+ }];
525
+ });
526
+ }
527
+ function findJsonLikeText(value) {
528
+ if (typeof value === "string") {
529
+ const trimmed = value.trim();
530
+ if (trimmed.startsWith("{") || trimmed.startsWith("```"))
531
+ return trimmed;
532
+ return null;
533
+ }
534
+ if (Array.isArray(value)) {
535
+ for (const entry of value) {
536
+ const found = findJsonLikeText(entry);
537
+ if (found)
538
+ return found;
539
+ }
540
+ return null;
541
+ }
542
+ if (!isRecord(value))
543
+ return null;
544
+ for (const key of ["text", "content", "message", "output_text", "response", "stdout"]) {
545
+ const found = findJsonLikeText(value[key]);
546
+ if (found)
547
+ return found;
548
+ }
549
+ for (const entry of Object.values(value)) {
550
+ const found = findJsonLikeText(entry);
551
+ if (found)
552
+ return found;
553
+ }
554
+ return null;
555
+ }
556
+ function candidateAnalysisObject(value) {
557
+ if (!isRecord(value))
558
+ return null;
559
+ if (isRecord(value.result))
560
+ return candidateAnalysisObject(value.result) ?? value.result;
561
+ if (isRecord(value.analysis))
562
+ return candidateAnalysisObject(value.analysis) ?? value.analysis;
563
+ if (isRecord(value.metadataPatch) || Array.isArray(value.labelsToAdd) || Array.isArray(value.labelsToRemove) || Array.isArray(value.generatedIssues)) {
564
+ return value;
565
+ }
566
+ const nested = findJsonLikeText(value);
567
+ if (nested && nested !== JSON.stringify(value)) {
568
+ try {
569
+ const parsedNested = JSON.parse(nested.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1]?.trim() ?? nested);
570
+ return candidateAnalysisObject(parsedNested);
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
575
+ return null;
576
+ }
577
+ function parseIssueAnalysisResult(raw) {
578
+ let parsed = raw;
579
+ if (typeof raw === "string") {
580
+ const trimmed = raw.trim();
581
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1]?.trim();
582
+ try {
583
+ parsed = JSON.parse(fenced ?? trimmed);
584
+ } catch {
585
+ const lastJsonLine = trimmed.split(/\r?\n/).reverse().find((line) => line.trim().startsWith("{"));
586
+ parsed = lastJsonLine ? JSON.parse(lastJsonLine) : {};
587
+ }
588
+ }
589
+ const candidate = candidateAnalysisObject(parsed);
590
+ if (!candidate)
591
+ return {};
592
+ const result = {};
593
+ if (isRecord(candidate.metadataPatch))
594
+ result.metadataPatch = candidate.metadataPatch;
595
+ const add = stringArray(candidate.labelsToAdd);
596
+ if (add?.length)
597
+ result.labelsToAdd = add;
598
+ const remove = stringArray(candidate.labelsToRemove);
599
+ if (remove?.length)
600
+ result.labelsToRemove = remove;
601
+ const generated = generatedIssues(candidate.generatedIssues);
602
+ if (generated?.length)
603
+ result.generatedIssues = generated;
604
+ return result;
605
+ }
606
+ function createDefaultPiIssueAnalysisCommandRunner() {
607
+ return (command, args, options) => new Promise((resolve6) => {
608
+ execFile(command, [...args], {
609
+ timeout: options.timeoutMs,
610
+ maxBuffer: 10 * 1024 * 1024,
611
+ env: options.env ? { ...process.env, ...options.env } : process.env
612
+ }, (error, stdout, stderr) => {
613
+ const exitCode = typeof error?.code === "number" ? error.code : error ? 1 : 0;
614
+ resolve6({ exitCode, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
615
+ });
616
+ });
617
+ }
618
+ function createPiIssueAnalyzer(input = {}) {
619
+ const piBinary = input.piBinary ?? process.env.RIG_ISSUE_ANALYSIS_PI_BINARY ?? "pi";
620
+ const timeoutMs = Math.max(1000, Math.trunc(input.timeoutMs ?? Number(process.env.RIG_ISSUE_ANALYSIS_TIMEOUT_MS ?? 120000)));
621
+ const runCommand = input.runCommand ?? createDefaultPiIssueAnalysisCommandRunner();
622
+ return async ({ prompt }) => {
623
+ const args = ["--print", "--mode", "json", "--no-session"];
624
+ const provider = input.provider?.trim() || process.env.RIG_ISSUE_ANALYSIS_PROVIDER?.trim() || process.env.RIG_PI_PROVIDER?.trim();
625
+ const model = input.model?.trim() || process.env.RIG_ISSUE_ANALYSIS_MODEL?.trim() || process.env.RIG_PI_MODEL?.trim() || "openai-codex/gpt-5.5";
626
+ if (provider)
627
+ args.push("--provider", provider);
628
+ if (model)
629
+ args.push("--model", model);
630
+ args.push(prompt);
631
+ const result = await runCommand(piBinary, args, { timeoutMs, ...input.env ? { env: input.env } : {} });
632
+ if (result.exitCode !== 0) {
633
+ throw new Error(`Pi issue analysis failed (exit ${result.exitCode}): ${result.stderr ?? result.stdout}`);
634
+ }
635
+ return parseIssueAnalysisResult(result.stdout);
636
+ };
637
+ }
638
+ function defaultStatusComment(input) {
639
+ const changes = [
640
+ input.result.metadataPatch ? "metadata" : null,
641
+ input.result.labelsToAdd?.length ? `labels added: ${input.result.labelsToAdd.join(", ")}` : null,
642
+ input.result.labelsToRemove?.length ? `labels removed: ${input.result.labelsToRemove.join(", ")}` : null,
643
+ input.result.generatedIssues?.length ? `generated issues: ${input.result.generatedIssues.length}` : null
644
+ ].filter((entry) => Boolean(entry));
645
+ if (changes.length === 0)
646
+ return null;
647
+ return [
648
+ "<!-- rig:status-comment -->",
649
+ "### Rig issue analysis",
650
+ "",
651
+ `Analyzed issue ${input.issue.id}${input.reason ? ` (${input.reason})` : ""}.`,
652
+ "",
653
+ ...changes.map((change) => `- ${change}`)
654
+ ].join(`
655
+ `);
656
+ }
657
+ function uniqueLabels(labels, required = []) {
658
+ return [...new Set([...labels ?? [], ...required].map((label) => label.trim()).filter(Boolean))];
659
+ }
660
+ function createIssueAnalysisWriteBack(input) {
661
+ return async ({ issue, result, reason }) => {
662
+ if (result.metadataPatch && Object.keys(result.metadataPatch).length > 0) {
663
+ if (!input.target.updateTask)
664
+ throw new Error("Issue analysis writeback requires updateTask for metadata patches.");
665
+ await input.target.updateTask(issue.id, { metadata: result.metadataPatch });
666
+ }
667
+ if (result.labelsToAdd?.length) {
668
+ if (!input.target.addLabels)
669
+ throw new Error("Issue analysis writeback requires addLabels for labelsToAdd.");
670
+ await input.target.addLabels(issue.id, uniqueLabels(result.labelsToAdd));
671
+ }
672
+ if (result.labelsToRemove?.length) {
673
+ if (!input.target.removeLabels)
674
+ throw new Error("Issue analysis writeback requires removeLabels for labelsToRemove.");
675
+ await input.target.removeLabels(issue.id, uniqueLabels(result.labelsToRemove));
676
+ }
677
+ const comment = (input.buildStatusComment ?? defaultStatusComment)({ issue, result, reason });
678
+ if (comment?.trim()) {
679
+ if (!input.target.updateTask)
680
+ throw new Error("Issue analysis writeback requires updateTask for sticky status comments.");
681
+ await input.target.updateTask(issue.id, { comment });
682
+ }
683
+ for (const generated of result.generatedIssues ?? []) {
684
+ if (!input.target.createIssue)
685
+ throw new Error("Issue analysis writeback requires createIssue for generated issues.");
686
+ await input.target.createIssue({
687
+ title: generated.title,
688
+ body: generated.dependsOn?.length ? `${generated.body.trimEnd()}
689
+
690
+ depends-on: ${generated.dependsOn.map((dep) => dep.startsWith("#") ? dep : `#${dep}`).join(", ")}
691
+ ` : generated.body,
692
+ labels: uniqueLabels(generated.labels, ["rig:generated"])
693
+ });
694
+ }
695
+ };
696
+ }
697
+ function createIssueAnalysisService(input) {
698
+ const analyzedHashes = new Map;
699
+ return {
700
+ async analyze(issues, options = {}) {
701
+ const results = [];
702
+ const neighbors = options.neighbors ?? issues;
703
+ for (const issue of issues) {
704
+ const hash = stableIssueHash(issue);
705
+ if (analyzedHashes.get(issue.id) === hash)
706
+ continue;
707
+ const prompt = renderIssueAnalysisPrompt({ issue, neighbors: neighbors.filter((candidate) => candidate.id !== issue.id) });
708
+ const result = await input.analyzer({ issue, neighbors, prompt });
709
+ analyzedHashes.set(issue.id, hash);
710
+ if (result.metadataPatch || result.labelsToAdd?.length || result.labelsToRemove?.length || result.generatedIssues?.length) {
711
+ await input.writeBack?.({ issue, result, reason: options.reason });
712
+ }
713
+ results.push({ issue, result });
714
+ }
715
+ return results;
716
+ },
717
+ clearCache() {
718
+ analyzedHashes.clear();
719
+ }
720
+ };
721
+ }
722
+
472
723
  // packages/server/src/server-helpers/terminal-runtime.ts
473
724
  import { WS_CHANNELS as WS_CHANNELS2 } from "@rig/contracts";
474
725
 
@@ -916,7 +1167,7 @@ var TERMINAL_RUN_STATUSES2 = new Set([
916
1167
  "needs-attention",
917
1168
  "stopped"
918
1169
  ]);
919
- var ORPHANABLE_LOCAL_RUN_STATUSES = new Set(["preparing", "running"]);
1170
+ var RESUMABLE_LOCAL_RUN_STATUSES = new Set(["created", "preparing", "running", "validating", "reviewing"]);
920
1171
 
921
1172
  // packages/server/src/server-helpers/ws-router.ts
922
1173
  import {
@@ -1223,7 +1474,7 @@ import {
1223
1474
  } from "@rig/runtime/control-plane/tasks/source-lifecycle";
1224
1475
 
1225
1476
  // packages/server/src/server-helpers/project-registry.ts
1226
- import { createHash } from "crypto";
1477
+ import { createHash as createHash2 } from "crypto";
1227
1478
  import { spawnSync } from "child_process";
1228
1479
  import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync5 } from "fs";
1229
1480
  import { dirname as dirname4, resolve as resolve9 } from "path";
@@ -1266,7 +1517,7 @@ function hashFile(path) {
1266
1517
  if (!path)
1267
1518
  return null;
1268
1519
  try {
1269
- return createHash("sha256").update(readFileSync4(path)).digest("hex");
1520
+ return createHash2("sha256").update(readFileSync4(path)).digest("hex");
1270
1521
  } catch {
1271
1522
  return null;
1272
1523
  }
@@ -2175,6 +2426,27 @@ function filterWorkspaceTasks(projectRoot, tasks, searchParams) {
2175
2426
  }
2176
2427
  return filtered;
2177
2428
  }
2429
+ function issueAnalysisTargetFor(source) {
2430
+ if (!source)
2431
+ return null;
2432
+ const candidate = source;
2433
+ if (typeof candidate.updateTask !== "function")
2434
+ return null;
2435
+ return {
2436
+ ...typeof candidate.get === "function" ? { get: candidate.get.bind(candidate) } : {},
2437
+ updateTask: candidate.updateTask.bind(candidate),
2438
+ ...typeof candidate.addLabels === "function" ? { addLabels: candidate.addLabels.bind(candidate) } : {},
2439
+ ...typeof candidate.removeLabels === "function" ? { removeLabels: candidate.removeLabels.bind(candidate) } : {},
2440
+ ...typeof candidate.createIssue === "function" ? { createIssue: candidate.createIssue.bind(candidate) } : {}
2441
+ };
2442
+ }
2443
+ function uniqueStringList(value) {
2444
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
2445
+ return [...new Set(raw.map((entry) => String(entry).trim()).filter(Boolean))];
2446
+ }
2447
+ function taskRecordId(task) {
2448
+ return String(task.id ?? "");
2449
+ }
2178
2450
  function redactRemoteEndpoint(endpoint) {
2179
2451
  const { token, ...rest } = endpoint;
2180
2452
  return {
@@ -2517,6 +2789,67 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2517
2789
  note: "GitHub issue lifecycle labels are created on demand by the configured task source when supported."
2518
2790
  });
2519
2791
  }
2792
+ if (url.pathname === "/api/workspace/issue-analysis/run" && req.method === "POST") {
2793
+ const body = await deps.readJsonBody(req);
2794
+ const ids = uniqueStringList(body.ids ?? body.id);
2795
+ const analyzeAll = deps.isTruthyQuery(String(body.all ?? ""));
2796
+ if (ids.length === 0 && !analyzeAll) {
2797
+ return deps.badRequest("ids is required unless all=true");
2798
+ }
2799
+ const ctx = await getCachedPluginHostContext(state.projectRoot);
2800
+ const [source] = ctx?.taskSourceRegistry.list() ?? [];
2801
+ const target = issueAnalysisTargetFor(source);
2802
+ if (!source || !target) {
2803
+ return deps.badRequest("Configured task source does not support issue-analysis writeback");
2804
+ }
2805
+ const allTasks = [...await source.list()];
2806
+ const issues = analyzeAll ? allTasks.slice(0, Math.max(1, Math.min(25, Number(body.limit ?? 25) || 25))) : (await Promise.all(ids.map(async (id) => {
2807
+ const cached = allTasks.find((task) => taskRecordId(task) === id);
2808
+ if (cached)
2809
+ return cached;
2810
+ return typeof source.get === "function" ? await source.get(id) : undefined;
2811
+ }))).filter((task) => Boolean(task));
2812
+ if (issues.length === 0) {
2813
+ return deps.jsonResponse({ ok: false, error: "No matching issues found for issue analysis", ids }, 404);
2814
+ }
2815
+ const config = ctx?.config && typeof ctx.config === "object" ? ctx.config : {};
2816
+ const issueAnalysis = config.issueAnalysis && typeof config.issueAnalysis === "object" ? config.issueAnalysis : {};
2817
+ const runtime = config.runtime && typeof config.runtime === "object" ? config.runtime : {};
2818
+ const model = normalizeString(issueAnalysis.model) ?? normalizeString(runtime.model);
2819
+ const service = createIssueAnalysisService({
2820
+ analyzer: createPiIssueAnalyzer({
2821
+ ...model ? { model } : {},
2822
+ env: { RIG_PROJECT_ROOT: state.projectRoot }
2823
+ }),
2824
+ writeBack: createIssueAnalysisWriteBack({ target })
2825
+ });
2826
+ const reason = normalizeString(body.reason) ?? "http-issue-analysis";
2827
+ let results;
2828
+ try {
2829
+ results = await service.analyze(issues, { reason, neighbors: ids.length > 0 ? issues : allTasks });
2830
+ } catch (error) {
2831
+ return deps.jsonResponse({
2832
+ ok: false,
2833
+ error: `Issue analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2834
+ reason,
2835
+ ids: issues.map((issue) => issue.id)
2836
+ }, 502);
2837
+ }
2838
+ deps.snapshotService.invalidate("issue-analysis-http-run");
2839
+ await state.taskProjectionReconciler?.tick("issue-analysis-http-run").catch(() => {
2840
+ return;
2841
+ });
2842
+ deps.broadcastSnapshotInvalidation(state, "issue-analysis-http-run");
2843
+ return deps.jsonResponse({
2844
+ ok: true,
2845
+ reason,
2846
+ analyzed: results.map((entry) => ({
2847
+ id: entry.issue.id,
2848
+ title: entry.issue.title ?? null,
2849
+ result: entry.result
2850
+ }))
2851
+ });
2852
+ }
2520
2853
  if (url.pathname === "/api/server/status") {
2521
2854
  const config = buildProjectConfigStatus(state.projectRoot);
2522
2855
  const taskSource = await buildTaskSourceStatus(state, config);
@@ -3212,11 +3545,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3212
3545
  const runId = normalizeString(body.runId);
3213
3546
  const createdAt = normalizeString(body.createdAt) ?? new Date().toISOString();
3214
3547
  const promptOverride = normalizeString(body.promptOverride);
3548
+ const restart = body.restart === true;
3215
3549
  if (!runId) {
3216
3550
  return deps.badRequest("runId is required");
3217
3551
  }
3218
3552
  try {
3219
- await deps.resumeRunRecord(state, { runId, createdAt, promptOverride });
3553
+ await deps.resumeRunRecord(state, { runId, createdAt, promptOverride, restart });
3220
3554
  deps.broadcastSnapshotInvalidation(state);
3221
3555
  return deps.jsonResponse({ ok: true, runId, createdAt });
3222
3556
  } catch (error) {
@@ -151,7 +151,10 @@ function createPiIssueAnalyzer(input = {}) {
151
151
  const runCommand = input.runCommand ?? createDefaultPiIssueAnalysisCommandRunner();
152
152
  return async ({ prompt }) => {
153
153
  const args = ["--print", "--mode", "json", "--no-session"];
154
- const model = input.model?.trim() || process.env.RIG_ISSUE_ANALYSIS_MODEL?.trim() || "openai-codex/gpt-5.5";
154
+ const provider = input.provider?.trim() || process.env.RIG_ISSUE_ANALYSIS_PROVIDER?.trim() || process.env.RIG_PI_PROVIDER?.trim();
155
+ const model = input.model?.trim() || process.env.RIG_ISSUE_ANALYSIS_MODEL?.trim() || process.env.RIG_PI_MODEL?.trim() || "openai-codex/gpt-5.5";
156
+ if (provider)
157
+ args.push("--provider", provider);
155
158
  if (model)
156
159
  args.push("--model", model);
157
160
  args.push(prompt);