fifony 0.1.13 → 0.1.14-next.045d1e1

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.
@@ -8,11 +8,11 @@ import {
8
8
  resolveTaskCapabilities
9
9
  } from "../chunk-JUSVR3DW.js";
10
10
 
11
- // src/runtime/run-local.ts
11
+ // src/agent/run-local.ts
12
12
  import { mkdirSync as mkdirSync5 } from "fs";
13
13
  import { env as env10, exit as exit2, argv as argv3 } from "process";
14
14
 
15
- // src/runtime/constants.ts
15
+ // src/agent/constants.ts
16
16
  import { existsSync } from "fs";
17
17
  import { basename, dirname, join, resolve } from "path";
18
18
  import { fileURLToPath } from "url";
@@ -86,8 +86,12 @@ var ALLOWED_STATES = [
86
86
  var TERMINAL_STATES = /* @__PURE__ */ new Set(["Done", "Cancelled"]);
87
87
  var EXECUTING_STATES = /* @__PURE__ */ new Set(["Running", "In Review"]);
88
88
  var PERSIST_EVENTS_MAX = 500;
89
+ var FAST_BOOT = CLI_ARGS.includes("--fast-boot");
90
+ var SKIP_SOURCE = FAST_BOOT || CLI_ARGS.includes("--skip-source");
91
+ var SKIP_SCAN = FAST_BOOT || CLI_ARGS.includes("--skip-scan");
92
+ var SKIP_RECOVERY = FAST_BOOT || CLI_ARGS.includes("--skip-recovery");
89
93
 
90
- // src/runtime/helpers.ts
94
+ // src/agent/helpers.ts
91
95
  import { env as env2 } from "process";
92
96
  import { parse as parseYaml } from "yaml";
93
97
  function now() {
@@ -224,8 +228,55 @@ function extractJsonObjects(text) {
224
228
  }
225
229
  return results;
226
230
  }
231
+ function repairTruncatedJson(text) {
232
+ const firstBrace = text.indexOf("{");
233
+ if (firstBrace < 0) return null;
234
+ let json = text.slice(firstBrace);
235
+ let inStr = false;
236
+ let esc = false;
237
+ const stack = [];
238
+ for (let i = 0; i < json.length; i++) {
239
+ const ch = json[i];
240
+ if (inStr) {
241
+ if (esc) {
242
+ esc = false;
243
+ continue;
244
+ }
245
+ if (ch === "\\") {
246
+ esc = true;
247
+ continue;
248
+ }
249
+ if (ch === '"') {
250
+ inStr = false;
251
+ }
252
+ continue;
253
+ }
254
+ if (ch === '"') {
255
+ inStr = true;
256
+ continue;
257
+ }
258
+ if (ch === "{") stack.push("{");
259
+ else if (ch === "[") stack.push("[");
260
+ else if (ch === "}") {
261
+ if (stack.length > 0 && stack[stack.length - 1] === "{") stack.pop();
262
+ } else if (ch === "]") {
263
+ if (stack.length > 0 && stack[stack.length - 1] === "[") stack.pop();
264
+ }
265
+ }
266
+ if (!inStr && stack.length === 0) return json;
267
+ if (inStr) {
268
+ if (json.endsWith("\\")) json = json.slice(0, -1);
269
+ json += '"';
270
+ }
271
+ json = json.replace(/[,:\s]+$/, "");
272
+ while (stack.length > 0) {
273
+ const open = stack.pop();
274
+ json += open === "{" ? "}" : "]";
275
+ }
276
+ return json;
277
+ }
227
278
 
228
- // src/runtime/logger.ts
279
+ // src/agent/logger.ts
229
280
  import pino from "pino";
230
281
  import { env as env3, stdout } from "process";
231
282
  import { join as join2 } from "path";
@@ -295,13 +346,13 @@ var logger = {
295
346
  }
296
347
  };
297
348
 
298
- // src/runtime/store.ts
299
- import { mkdirSync as mkdirSync3 } from "fs";
349
+ // src/agent/store.ts
350
+ import { mkdirSync as mkdirSync4 } from "fs";
300
351
 
301
- // src/runtime/issues.ts
352
+ // src/agent/issues.ts
302
353
  import { env as env4 } from "process";
303
354
 
304
- // src/runtime/token-ledger.ts
355
+ // src/agent/token-ledger.ts
305
356
  var EMPTY = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
306
357
  var overall = { ...EMPTY };
307
358
  var byPhase = /* @__PURE__ */ new Map();
@@ -463,7 +514,51 @@ function getAnalytics(topN = 20) {
463
514
  };
464
515
  }
465
516
 
466
- // src/runtime/issue-state-machine.ts
517
+ // src/agent/dirty-tracker.ts
518
+ var dirtyIssueIds = /* @__PURE__ */ new Set();
519
+ var dirtyEventIds = /* @__PURE__ */ new Set();
520
+ function markIssueDirty(id) {
521
+ dirtyIssueIds.add(id);
522
+ }
523
+ function markEventDirty(id) {
524
+ dirtyEventIds.add(id);
525
+ }
526
+ function hasDirtyState() {
527
+ return dirtyIssueIds.size > 0 || dirtyEventIds.size > 0;
528
+ }
529
+ function getDirtyIssueIds() {
530
+ return dirtyIssueIds;
531
+ }
532
+ function getDirtyEventIds() {
533
+ return dirtyEventIds;
534
+ }
535
+ function clearDirtyIssueIds() {
536
+ dirtyIssueIds.clear();
537
+ }
538
+ function clearDirtyEventIds() {
539
+ dirtyEventIds.clear();
540
+ }
541
+ function markAllIssuesDirty(ids) {
542
+ for (const id of ids) dirtyIssueIds.add(id);
543
+ }
544
+ function markAllEventsDirty(ids) {
545
+ for (const id of ids) dirtyEventIds.add(id);
546
+ }
547
+
548
+ // src/agent/metrics-cache.ts
549
+ var cachedMetrics = null;
550
+ var metricsStale = true;
551
+ function invalidateMetrics() {
552
+ metricsStale = true;
553
+ }
554
+ function getMetrics(issues) {
555
+ if (!metricsStale && cachedMetrics) return cachedMetrics;
556
+ cachedMetrics = computeMetrics(issues);
557
+ metricsStale = false;
558
+ return cachedMetrics;
559
+ }
560
+
561
+ // src/agent/issue-state-machine.ts
467
562
  var ISSUE_STATE_MACHINE_ID = "issue-lifecycle";
468
563
  var ISSUE_STATE_MACHINE_DEFINITION = {
469
564
  initialState: "Planning",
@@ -592,13 +687,13 @@ function findIssueStateMachineTransitionPath(machineDefinition, from, to) {
592
687
  return null;
593
688
  }
594
689
 
595
- // src/runtime/providers.ts
690
+ // src/agent/providers.ts
596
691
  import { execFileSync, spawn } from "child_process";
597
692
  import { existsSync as existsSync2, readFileSync } from "fs";
598
693
  import { join as join3 } from "path";
599
694
  import { homedir as homedir2 } from "os";
600
695
 
601
- // src/runtime/adapters/commands.ts
696
+ // src/agent/adapters/commands.ts
602
697
  var CLAUDE_RESULT_SCHEMA = JSON.stringify({
603
698
  type: "object",
604
699
  properties: {
@@ -666,7 +761,7 @@ function extractPlanDirs(plan) {
666
761
  return [...dirs];
667
762
  }
668
763
 
669
- // src/runtime/providers.ts
764
+ // src/agent/providers.ts
670
765
  function resolveAgentProfile(name) {
671
766
  const normalized = name.trim();
672
767
  if (!normalized) return { profilePath: "", instructions: "" };
@@ -720,7 +815,13 @@ function getProviderDefaultCommand(provider, _reasoningEffort, model) {
720
815
  if (provider === "claude") return buildClaudeCommand({ model, jsonSchema: CLAUDE_RESULT_SCHEMA });
721
816
  return "";
722
817
  }
818
+ var cachedProviders = null;
819
+ var providersCachedAt = 0;
820
+ var PROVIDER_CACHE_TTL = 6e4;
723
821
  function detectAvailableProviders() {
822
+ if (cachedProviders && Date.now() - providersCachedAt < PROVIDER_CACHE_TTL) {
823
+ return cachedProviders;
824
+ }
724
825
  const providers = [];
725
826
  for (const name of ["claude", "codex"]) {
726
827
  try {
@@ -730,6 +831,8 @@ function detectAvailableProviders() {
730
831
  providers.push({ name, available: false, path: "" });
731
832
  }
732
833
  }
834
+ cachedProviders = providers;
835
+ providersCachedAt = Date.now();
733
836
  return providers;
734
837
  }
735
838
  var modelCache = /* @__PURE__ */ new Map();
@@ -1056,7 +1159,7 @@ function getEffectiveAgentProviders(state, issue, workflowDefinition, workflowCo
1056
1159
  return applyWorkflowConfigToProviders(merged, workflowConfig ?? null);
1057
1160
  }
1058
1161
 
1059
- // src/runtime/issues.ts
1162
+ // src/agent/issues.ts
1060
1163
  var VALID_EFFORTS = /* @__PURE__ */ new Set(["low", "medium", "high", "extra-high"]);
1061
1164
  function parseEffortValue(value) {
1062
1165
  const str = typeof value === "string" ? value.trim().toLowerCase() : "";
@@ -1091,6 +1194,7 @@ function nextLocalIssueId(issues) {
1091
1194
  function createIssueFromPayload(payload, issues, workflowDefinition) {
1092
1195
  const identifier = toStringValue(payload.identifier, nextLocalIssueId(issues));
1093
1196
  const id = toStringValue(payload.id, identifier.replace(/^#/, "issue-"));
1197
+ logger.info({ id, identifier, title: toStringValue(payload.title, "").slice(0, 80) }, "[Issues] Creating new issue");
1094
1198
  const createdAt = now();
1095
1199
  const blockedBy = toStringArray(payload.blockedBy);
1096
1200
  const legacyBlockedBy = toStringArray(payload.blocked_by);
@@ -1281,6 +1385,7 @@ function buildRuntimeState(previous, config, definition) {
1281
1385
  };
1282
1386
  }
1283
1387
  function computeMetrics(issues) {
1388
+ let planning = 0;
1284
1389
  let queued = 0;
1285
1390
  let inProgress = 0;
1286
1391
  let blocked = 0;
@@ -1296,6 +1401,9 @@ function computeMetrics(issues) {
1296
1401
  }
1297
1402
  }
1298
1403
  switch (issue.state) {
1404
+ case "Planning":
1405
+ planning += 1;
1406
+ break;
1299
1407
  case "Todo":
1300
1408
  queued += 1;
1301
1409
  break;
@@ -1319,6 +1427,7 @@ function computeMetrics(issues) {
1319
1427
  if (completionTimes.length === 0) {
1320
1428
  return {
1321
1429
  total: issues.length,
1430
+ planning,
1322
1431
  queued,
1323
1432
  inProgress,
1324
1433
  blocked,
@@ -1333,6 +1442,7 @@ function computeMetrics(issues) {
1333
1442
  const medianCompletionMs = sortedCompletionTimes.length % 2 === 1 ? sortedCompletionTimes[mid] : Math.round((sortedCompletionTimes[mid - 1] + sortedCompletionTimes[mid]) / 2);
1334
1443
  return {
1335
1444
  total: issues.length,
1445
+ planning,
1336
1446
  queued,
1337
1447
  inProgress,
1338
1448
  blocked,
@@ -1361,6 +1471,7 @@ function addEvent(state, issueId, kind, message) {
1361
1471
  at: now()
1362
1472
  };
1363
1473
  state.events = [event, ...state.events].slice(0, PERSIST_EVENTS_MAX);
1474
+ markEventDirty(event.id);
1364
1475
  try {
1365
1476
  recordEvent();
1366
1477
  } catch {
@@ -1369,8 +1480,11 @@ function addEvent(state, issueId, kind, message) {
1369
1480
  }
1370
1481
  function transition(issue, target, note) {
1371
1482
  const previous = issue.state;
1483
+ logger.debug({ issueId: issue.id, identifier: issue.identifier, from: previous, to: target, note }, "[State] Issue transition");
1372
1484
  issue.state = target;
1373
1485
  issue.updatedAt = now();
1486
+ markIssueDirty(issue.id);
1487
+ invalidateMetrics();
1374
1488
  issue.history.push(`[${issue.updatedAt}] ${note}`);
1375
1489
  if (previous === "Blocked" && target === "Todo") {
1376
1490
  issue.lastError = void 0;
@@ -1501,7 +1615,7 @@ async function handleStatePatch(state, issue, payload) {
1501
1615
  addEvent(state, issue.id, "manual", `Manual state transition to ${nextState}`);
1502
1616
  }
1503
1617
 
1504
- // src/runtime/api-runtime-context.ts
1618
+ // src/agent/api-runtime-context.ts
1505
1619
  var context = null;
1506
1620
  function setApiRuntimeContext(state, workflowDefinition) {
1507
1621
  context = { state, workflowDefinition };
@@ -1516,18 +1630,20 @@ function getApiRuntimeContextOrThrow() {
1516
1630
  return context;
1517
1631
  }
1518
1632
 
1519
- // src/runtime/api-server.ts
1633
+ // src/agent/api-server.ts
1520
1634
  import { execSync as execSync3 } from "child_process";
1521
1635
  import {
1636
+ appendFileSync as appendFileSync2,
1522
1637
  closeSync,
1523
- existsSync as existsSync9,
1638
+ existsSync as existsSync10,
1524
1639
  openSync,
1525
- readFileSync as readFileSync8,
1640
+ readFileSync as readFileSync9,
1526
1641
  readSync,
1527
- statSync as statSync2
1642
+ statSync as statSync3,
1643
+ writeFileSync as writeFileSync8
1528
1644
  } from "fs";
1529
1645
 
1530
- // src/runtime/resources/runtime-state.resource.ts
1646
+ // src/agent/resources/runtime-state.resource.ts
1531
1647
  var runtime_state_resource_default = {
1532
1648
  name: S3DB_RUNTIME_RESOURCE,
1533
1649
  attributes: {
@@ -1548,23 +1664,23 @@ var runtime_state_resource_default = {
1548
1664
  }
1549
1665
  };
1550
1666
 
1551
- // src/runtime/agent.ts
1667
+ // src/agent/agent.ts
1552
1668
  import {
1553
1669
  appendFileSync,
1554
1670
  cpSync,
1555
- existsSync as existsSync4,
1556
- mkdirSync,
1557
- readdirSync as readdirSync2,
1558
- readFileSync as readFileSync3,
1671
+ existsSync as existsSync5,
1672
+ mkdirSync as mkdirSync2,
1673
+ readdirSync as readdirSync3,
1674
+ readFileSync as readFileSync4,
1559
1675
  rmSync,
1560
- statSync,
1561
- writeFileSync as writeFileSync2
1676
+ statSync as statSync2,
1677
+ writeFileSync as writeFileSync3
1562
1678
  } from "fs";
1563
- import { join as join7, relative } from "path";
1564
- import { env as env5 } from "process";
1679
+ import { join as join8, relative } from "path";
1680
+ import { env as env6 } from "process";
1565
1681
  import { execSync, spawn as spawn2 } from "child_process";
1566
1682
 
1567
- // src/runtime/skills.ts
1683
+ // src/agent/skills.ts
1568
1684
  import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2 } from "fs";
1569
1685
  import { homedir as homedir3 } from "os";
1570
1686
  import { join as join4, resolve as resolve2 } from "path";
@@ -1608,11 +1724,143 @@ ${skill.content}`
1608
1724
  ${sections.join("\n\n")}`;
1609
1725
  }
1610
1726
 
1611
- // src/runtime/adapters/index.ts
1612
- import { writeFileSync } from "fs";
1613
- import { join as join6 } from "path";
1727
+ // src/agent/workflow.ts
1728
+ import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync, writeFileSync } from "fs";
1729
+ import { copyFile, mkdir, readdir, stat, writeFile } from "fs/promises";
1730
+ import { extname } from "path";
1731
+ import { argv as argv2, exit } from "process";
1732
+ var sourceReadyPromise = null;
1733
+ var skipSourceFlag = false;
1734
+ function setSkipSource(skip) {
1735
+ skipSourceFlag = skip;
1736
+ }
1737
+ async function ensureSourceReady(onProgress) {
1738
+ if (skipSourceFlag) {
1739
+ onProgress?.("ready");
1740
+ return;
1741
+ }
1742
+ if (existsSync4(SOURCE_MARKER)) {
1743
+ onProgress?.("ready");
1744
+ return;
1745
+ }
1746
+ if (sourceReadyPromise) return sourceReadyPromise;
1747
+ sourceReadyPromise = (async () => {
1748
+ onProgress?.("copying");
1749
+ logger.info("Creating local source snapshot (async) for Fifony...");
1750
+ const skipDirs = /* @__PURE__ */ new Set([
1751
+ ".git",
1752
+ ".fifony",
1753
+ "node_modules",
1754
+ ".venv",
1755
+ "data",
1756
+ "dist",
1757
+ "build",
1758
+ ".turbo",
1759
+ ".next",
1760
+ ".nuxt",
1761
+ ".tanstack",
1762
+ "coverage",
1763
+ "artifacts",
1764
+ "captures",
1765
+ "tmp",
1766
+ "temp"
1767
+ ]);
1768
+ const shouldSkip = (relativePath) => {
1769
+ const parts = relativePath.split("/");
1770
+ if (parts.some((segment) => skipDirs.has(segment))) return true;
1771
+ const base = relativePath.split("/").at(-1) ?? "";
1772
+ if (base.startsWith("map_scan_") && extname(base) === ".json") return true;
1773
+ if (extname(base) === ".xlsx") return true;
1774
+ return false;
1775
+ };
1776
+ const copyRecursiveAsync = async (source, target, rel = "") => {
1777
+ await mkdir(target, { recursive: true });
1778
+ const items = await readdir(source, { withFileTypes: true });
1779
+ for (const item of items) {
1780
+ const nextRel = rel ? `${rel}/${item.name}` : item.name;
1781
+ if (shouldSkip(nextRel)) continue;
1782
+ const sourcePath = `${source}/${item.name}`;
1783
+ const targetPath = `${target}/${item.name}`;
1784
+ const itemStat = await stat(sourcePath);
1785
+ if (item.isDirectory()) {
1786
+ await copyRecursiveAsync(sourcePath, targetPath, nextRel);
1787
+ continue;
1788
+ }
1789
+ if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
1790
+ if (itemStat.isFile() || itemStat.isFIFO()) {
1791
+ try {
1792
+ await copyFile(sourcePath, targetPath);
1793
+ } catch (error) {
1794
+ if (error.code === "ENOENT") {
1795
+ logger.debug(`Skipped missing source file: ${sourcePath}`);
1796
+ } else {
1797
+ throw error;
1798
+ }
1799
+ }
1800
+ }
1801
+ }
1802
+ };
1803
+ await mkdir(SOURCE_ROOT, { recursive: true });
1804
+ await copyRecursiveAsync(TARGET_ROOT, SOURCE_ROOT);
1805
+ await writeFile(SOURCE_MARKER, `${now()}
1806
+ `, "utf8");
1807
+ onProgress?.("ready");
1808
+ logger.info("Source snapshot ready (async).");
1809
+ })();
1810
+ return sourceReadyPromise;
1811
+ }
1812
+ function loadWorkflowDefinition() {
1813
+ const defaultPrompt = PROMPT_TEMPLATES["workflow-default"];
1814
+ return {
1815
+ workflowPath: "",
1816
+ rendered: "",
1817
+ config: {},
1818
+ promptTemplate: defaultPrompt,
1819
+ agentProvider: "codex",
1820
+ agentProfile: "",
1821
+ agentProfilePath: "",
1822
+ agentProfileInstructions: "",
1823
+ agentProviders: [],
1824
+ afterCreateHook: "",
1825
+ beforeRunHook: "",
1826
+ afterRunHook: "",
1827
+ beforeRemoveHook: ""
1828
+ };
1829
+ }
1830
+ function parsePort(args) {
1831
+ for (let i = 0; i < args.length; i += 1) {
1832
+ const arg = args[i];
1833
+ if (arg === "--help" || arg === "-h") {
1834
+ console.log(
1835
+ `Usage: ${argv2[1]} [options]
1836
+ Options:
1837
+ --port <n> Start local dashboard (default: no UI and single batch run)
1838
+ --workspace <path> Target workspace root (default: current directory)
1839
+ --persistence <path> Persistence root (default: current directory)
1840
+ --concurrency <n> Maximum number of parallel issue runners
1841
+ --attempts <n> Maximum attempts per issue
1842
+ --poll <ms> Polling interval for the scheduler
1843
+ --once Run one local batch and exit
1844
+ --help Show this message`
1845
+ );
1846
+ exit(0);
1847
+ }
1848
+ if (arg === "--port") {
1849
+ const value = args[i + 1];
1850
+ if (!value || !/^\d+$/.test(value)) {
1851
+ fail(`Invalid value for --port: ${value ?? "<empty>"}`);
1852
+ }
1853
+ return parseIntArg(value, 4040);
1854
+ }
1855
+ }
1856
+ return void 0;
1857
+ }
1858
+
1859
+ // src/agent/adapters/index.ts
1860
+ import { writeFileSync as writeFileSync2 } from "fs";
1861
+ import { join as join7 } from "path";
1614
1862
 
1615
- // src/runtime/adapters/shared.ts
1863
+ // src/agent/adapters/shared.ts
1616
1864
  function buildPlanContextSection(plan) {
1617
1865
  const parts = ["## Plan Context", "", `**Summary:** ${plan.summary}`];
1618
1866
  if (plan.assumptions?.length) {
@@ -1810,7 +2058,7 @@ function buildExecutionPayload(issue, provider, plan, workspacePath) {
1810
2058
  };
1811
2059
  }
1812
2060
 
1813
- // src/runtime/adapters/plan-to-claude.ts
2061
+ // src/agent/adapters/plan-to-claude.ts
1814
2062
  async function compileForClaude(issue, provider, plan, config, workspacePath, skillContext) {
1815
2063
  const effort = resolveEffortForProvider(plan, provider.role, config.defaultEffort);
1816
2064
  const prompt = await renderPrompt("compile-execution-claude", {
@@ -1861,8 +2109,8 @@ async function compileForClaude(issue, provider, plan, config, workspacePath, sk
1861
2109
  };
1862
2110
  }
1863
2111
 
1864
- // src/runtime/adapters/plan-to-codex.ts
1865
- import { join as join5 } from "path";
2112
+ // src/agent/adapters/plan-to-codex.ts
2113
+ import { join as join6 } from "path";
1866
2114
  var CODEX_RESULT_CONTRACT = `
1867
2115
  Return a JSON object with this exact schema when finished:
1868
2116
  {
@@ -1899,7 +2147,7 @@ async function compileForCodex(issue, provider, plan, config, workspacePath, ski
1899
2147
  outputContract: CODEX_RESULT_CONTRACT
1900
2148
  });
1901
2149
  const relativeDirs = extractPlanDirs(plan);
1902
- const absoluteDirs = relativeDirs.map((d) => join5(workspacePath, d));
2150
+ const absoluteDirs = relativeDirs.map((d) => join6(workspacePath, d));
1903
2151
  const command = buildCodexCommand({
1904
2152
  model: provider.model,
1905
2153
  addDirs: absoluteDirs
@@ -1931,7 +2179,7 @@ async function compileForCodex(issue, provider, plan, config, workspacePath, ski
1931
2179
  };
1932
2180
  }
1933
2181
 
1934
- // src/runtime/adapters/index.ts
2182
+ // src/agent/adapters/index.ts
1935
2183
  async function compileExecution(issue, provider, config, workspacePath, skillContext) {
1936
2184
  const plan = issue.plan;
1937
2185
  if (!plan?.steps?.length) return null;
@@ -1987,8 +2235,8 @@ function buildExecutionAudit(provider, compiled, issue, durationMs, result) {
1987
2235
  }
1988
2236
  function persistCompilationArtifacts(workspacePath, compiled) {
1989
2237
  try {
1990
- writeFileSync(
1991
- join6(workspacePath, "fifony-compiled-execution.json"),
2238
+ writeFileSync2(
2239
+ join7(workspacePath, "fifony-compiled-execution.json"),
1992
2240
  JSON.stringify({
1993
2241
  adapter: compiled.meta.adapter,
1994
2242
  model: compiled.meta.model,
@@ -2010,8 +2258,8 @@ function persistCompilationArtifacts(workspacePath, compiled) {
2010
2258
  }
2011
2259
  if (compiled.payload) {
2012
2260
  try {
2013
- writeFileSync(
2014
- join6(workspacePath, "fifony-execution-payload.json"),
2261
+ writeFileSync2(
2262
+ join7(workspacePath, "fifony-execution-payload.json"),
2015
2263
  JSON.stringify(compiled.payload, null, 2),
2016
2264
  "utf8"
2017
2265
  );
@@ -2021,8 +2269,8 @@ function persistCompilationArtifacts(workspacePath, compiled) {
2021
2269
  }
2022
2270
  function persistExecutionAudit(workspacePath, audit) {
2023
2271
  try {
2024
- writeFileSync(
2025
- join6(workspacePath, "fifony-execution-audit.json"),
2272
+ writeFileSync2(
2273
+ join7(workspacePath, "fifony-execution-audit.json"),
2026
2274
  JSON.stringify(audit, null, 2),
2027
2275
  "utf8"
2028
2276
  );
@@ -2030,7 +2278,7 @@ function persistExecutionAudit(workspacePath, audit) {
2030
2278
  }
2031
2279
  }
2032
2280
 
2033
- // src/runtime/settings.ts
2281
+ // src/agent/settings.ts
2034
2282
  var SETTING_ID_POLL_INTERVAL_MS = "runtime.pollIntervalMs";
2035
2283
  var SETTING_ID_WORKER_CONCURRENCY = "runtime.workerConcurrency";
2036
2284
  var SETTING_ID_COMMAND_TIMEOUT_MS = "runtime.commandTimeoutMs";
@@ -2311,7 +2559,7 @@ async function persistWorkflowConfig(config) {
2311
2559
  await persistSetting(SETTING_ID_WORKFLOW_CONFIG, config, { scope: "runtime", source: "user" });
2312
2560
  }
2313
2561
 
2314
- // src/runtime/agent.ts
2562
+ // src/agent/agent.ts
2315
2563
  function normalizeAgentDirectiveStatus(value, fallback) {
2316
2564
  const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
2317
2565
  if (normalized === "done" || normalized === "continue" || normalized === "blocked" || normalized === "failed") {
@@ -2412,7 +2660,7 @@ function tryParseJsonOutput(output) {
2412
2660
  }
2413
2661
  function readAgentDirective(workspacePath, output, success) {
2414
2662
  const fallbackStatus = success ? "done" : "failed";
2415
- const resultFile = join7(workspacePath, "fifony-result.json");
2663
+ const resultFile = join8(workspacePath, "fifony-result.json");
2416
2664
  let resultPayload = {};
2417
2665
  const fullJson = (() => {
2418
2666
  try {
@@ -2431,9 +2679,9 @@ function readAgentDirective(workspacePath, output, success) {
2431
2679
  tokenUsage
2432
2680
  };
2433
2681
  }
2434
- if (existsSync4(resultFile)) {
2682
+ if (existsSync5(resultFile)) {
2435
2683
  try {
2436
- const parsed = JSON.parse(readFileSync3(resultFile, "utf8"));
2684
+ const parsed = JSON.parse(readFileSync4(resultFile, "utf8"));
2437
2685
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2438
2686
  resultPayload = parsed;
2439
2687
  }
@@ -2450,10 +2698,10 @@ function readAgentDirective(workspacePath, output, success) {
2450
2698
  return { status, summary, nextPrompt, tokenUsage };
2451
2699
  }
2452
2700
  function readAgentPid(workspacePath) {
2453
- const pidFile = join7(workspacePath, "fifony-agent.pid");
2454
- if (!existsSync4(pidFile)) return null;
2701
+ const pidFile = join8(workspacePath, "fifony-agent.pid");
2702
+ if (!existsSync5(pidFile)) return null;
2455
2703
  try {
2456
- const data = JSON.parse(readFileSync3(pidFile, "utf8"));
2704
+ const data = JSON.parse(readFileSync4(pidFile, "utf8"));
2457
2705
  if (!data?.pid || typeof data.pid !== "number") return null;
2458
2706
  return data;
2459
2707
  } catch {
@@ -2470,7 +2718,7 @@ function isProcessAlive(pid) {
2470
2718
  }
2471
2719
  function isAgentStillRunning(issue) {
2472
2720
  const wp = issue.workspacePath;
2473
- if (!wp || !existsSync4(wp)) return { alive: false, pid: null };
2721
+ if (!wp || !existsSync5(wp)) return { alive: false, pid: null };
2474
2722
  const pidInfo = readAgentPid(wp);
2475
2723
  if (!pidInfo) return { alive: false, pid: null };
2476
2724
  return { alive: isProcessAlive(pidInfo.pid), pid: pidInfo };
@@ -2480,7 +2728,7 @@ function cleanStalePidFile(workspacePath) {
2480
2728
  if (!pidInfo) return;
2481
2729
  if (!isProcessAlive(pidInfo.pid)) {
2482
2730
  try {
2483
- rmSync(join7(workspacePath, "fifony-agent.pid"), { force: true });
2731
+ rmSync(join8(workspacePath, "fifony-agent.pid"), { force: true });
2484
2732
  } catch {
2485
2733
  }
2486
2734
  }
@@ -2490,13 +2738,22 @@ function canRunIssue(issue, running, state) {
2490
2738
  if (running.has(issue.id)) return false;
2491
2739
  if (TERMINAL_STATES.has(issue.state)) return false;
2492
2740
  const { alive } = isAgentStillRunning(issue);
2493
- if (alive) return false;
2741
+ if (alive) {
2742
+ logger.debug({ issueId: issue.id, identifier: issue.identifier }, "[Agent] Skipping issue \u2014 agent still alive from previous session");
2743
+ return false;
2744
+ }
2494
2745
  if (issue.state === "Blocked") {
2495
2746
  if (!issue.nextRetryAt) return false;
2496
- if (issue.attempts >= issue.maxAttempts) return false;
2747
+ if (issue.attempts >= issue.maxAttempts) {
2748
+ logger.debug({ issueId: issue.id, identifier: issue.identifier, attempts: issue.attempts, maxAttempts: issue.maxAttempts }, "[Agent] Skipping blocked issue \u2014 max attempts reached");
2749
+ return false;
2750
+ }
2497
2751
  if (Date.parse(issue.nextRetryAt) > Date.now()) return false;
2498
2752
  }
2499
- if (!issueDepsResolved(issue, state.issues)) return false;
2753
+ if (!issueDepsResolved(issue, state.issues)) {
2754
+ logger.debug({ issueId: issue.id, identifier: issue.identifier, blockedBy: issue.blockedBy }, "[Agent] Skipping issue \u2014 unresolved dependencies");
2755
+ return false;
2756
+ }
2500
2757
  if (issue.state === "Todo") return true;
2501
2758
  if (issue.state === "Queued") return true;
2502
2759
  if (issue.state === "Blocked") return true;
@@ -2522,33 +2779,33 @@ function shouldSkipRoutingPath(relativePath) {
2522
2779
  return base === "WORKFLOW.local.md" || base === ".fifony-env.sh" || base.startsWith("fifony-") || base.startsWith("fifony_");
2523
2780
  }
2524
2781
  function inferChangedWorkspacePaths(workspacePath, limit = 32) {
2525
- if (!workspacePath || !existsSync4(workspacePath) || !existsSync4(SOURCE_ROOT)) return [];
2782
+ if (!workspacePath || !existsSync5(workspacePath) || !existsSync5(SOURCE_ROOT)) return [];
2526
2783
  const changed = /* @__PURE__ */ new Set();
2527
2784
  const walk = (currentRoot, relativeRoot = "") => {
2528
2785
  if (changed.size >= limit) return;
2529
- for (const item of readdirSync2(currentRoot, { withFileTypes: true })) {
2786
+ for (const item of readdirSync3(currentRoot, { withFileTypes: true })) {
2530
2787
  if (changed.size >= limit) return;
2531
2788
  const nextRelative = relativeRoot ? `${relativeRoot}/${item.name}` : item.name;
2532
2789
  if (shouldSkipRoutingPath(nextRelative)) continue;
2533
- const currentPath = join7(currentRoot, item.name);
2790
+ const currentPath = join8(currentRoot, item.name);
2534
2791
  if (item.isDirectory()) {
2535
2792
  walk(currentPath, nextRelative);
2536
2793
  continue;
2537
2794
  }
2538
2795
  if (!item.isFile()) continue;
2539
- const sourcePath = join7(SOURCE_ROOT, nextRelative);
2540
- if (!existsSync4(sourcePath)) {
2796
+ const sourcePath = join8(SOURCE_ROOT, nextRelative);
2797
+ if (!existsSync5(sourcePath)) {
2541
2798
  changed.add(nextRelative);
2542
2799
  continue;
2543
2800
  }
2544
- const currentStat = statSync(currentPath);
2545
- const sourceStat = statSync(sourcePath);
2801
+ const currentStat = statSync2(currentPath);
2802
+ const sourceStat = statSync2(sourcePath);
2546
2803
  if (currentStat.size !== sourceStat.size) {
2547
2804
  changed.add(nextRelative);
2548
2805
  continue;
2549
2806
  }
2550
- const currentFile = readFileSync3(currentPath);
2551
- const sourceFile = readFileSync3(sourcePath);
2807
+ const currentFile = readFileSync4(currentPath);
2808
+ const sourceFile = readFileSync4(sourcePath);
2552
2809
  if (!currentFile.equals(sourceFile)) changed.add(nextRelative);
2553
2810
  }
2554
2811
  };
@@ -2557,7 +2814,7 @@ function inferChangedWorkspacePaths(workspacePath, limit = 32) {
2557
2814
  }
2558
2815
  function computeDiffStats(issue) {
2559
2816
  const wp = issue.workspacePath;
2560
- if (!wp || !existsSync4(wp) || !existsSync4(SOURCE_ROOT)) return;
2817
+ if (!wp || !existsSync5(wp) || !existsSync5(SOURCE_ROOT)) return;
2561
2818
  try {
2562
2819
  let raw = "";
2563
2820
  try {
@@ -2586,23 +2843,23 @@ function computeDiffStats(issue) {
2586
2843
  }
2587
2844
  }
2588
2845
  function isConflict(relativePath) {
2589
- const targetPath = join7(TARGET_ROOT, relativePath);
2590
- const sourcePath = join7(SOURCE_ROOT, relativePath);
2591
- if (!existsSync4(sourcePath)) return existsSync4(targetPath);
2592
- if (!existsSync4(targetPath)) return false;
2593
- const targetStat = statSync(targetPath);
2594
- const sourceStat = statSync(sourcePath);
2846
+ const targetPath = join8(TARGET_ROOT, relativePath);
2847
+ const sourcePath = join8(SOURCE_ROOT, relativePath);
2848
+ if (!existsSync5(sourcePath)) return existsSync5(targetPath);
2849
+ if (!existsSync5(targetPath)) return false;
2850
+ const targetStat = statSync2(targetPath);
2851
+ const sourceStat = statSync2(sourcePath);
2595
2852
  if (targetStat.size !== sourceStat.size) return true;
2596
- return !readFileSync3(targetPath).equals(readFileSync3(sourcePath));
2853
+ return !readFileSync4(targetPath).equals(readFileSync4(sourcePath));
2597
2854
  }
2598
2855
  function mergeWorkspace(workspacePath) {
2599
2856
  const result = { copied: [], deleted: [], skipped: [], conflicts: [] };
2600
- if (!workspacePath || !existsSync4(workspacePath)) {
2857
+ if (!workspacePath || !existsSync5(workspacePath)) {
2601
2858
  throw new Error(`Workspace not found: ${workspacePath}`);
2602
2859
  }
2603
2860
  const walkWorkspace = (dir) => {
2604
- for (const item of readdirSync2(dir, { withFileTypes: true })) {
2605
- const fullPath = join7(dir, item.name);
2861
+ for (const item of readdirSync3(dir, { withFileTypes: true })) {
2862
+ const fullPath = join8(dir, item.name);
2606
2863
  const relativePath = relative(workspacePath, fullPath);
2607
2864
  if (shouldSkipMergePath(relativePath)) {
2608
2865
  result.skipped.push(relativePath);
@@ -2613,17 +2870,17 @@ function mergeWorkspace(workspacePath) {
2613
2870
  continue;
2614
2871
  }
2615
2872
  if (!item.isFile()) continue;
2616
- const sourcePath = join7(SOURCE_ROOT, relativePath);
2617
- const isNew = !existsSync4(sourcePath);
2873
+ const sourcePath = join8(SOURCE_ROOT, relativePath);
2874
+ const isNew = !existsSync5(sourcePath);
2618
2875
  let isModified = false;
2619
2876
  if (!isNew) {
2620
- const wsStat = statSync(fullPath);
2621
- const srcStat = statSync(sourcePath);
2877
+ const wsStat = statSync2(fullPath);
2878
+ const srcStat = statSync2(sourcePath);
2622
2879
  if (wsStat.size !== srcStat.size) {
2623
2880
  isModified = true;
2624
2881
  } else {
2625
- const wsContent = readFileSync3(fullPath);
2626
- const srcContent = readFileSync3(sourcePath);
2882
+ const wsContent = readFileSync4(fullPath);
2883
+ const srcContent = readFileSync4(sourcePath);
2627
2884
  isModified = !wsContent.equals(srcContent);
2628
2885
  }
2629
2886
  }
@@ -2632,18 +2889,18 @@ function mergeWorkspace(workspacePath) {
2632
2889
  result.conflicts.push(relativePath);
2633
2890
  continue;
2634
2891
  }
2635
- const targetDir = join7(TARGET_ROOT, relative(workspacePath, dir));
2636
- const targetPath = join7(TARGET_ROOT, relativePath);
2637
- mkdirSync(targetDir, { recursive: true });
2892
+ const targetDir = join8(TARGET_ROOT, relative(workspacePath, dir));
2893
+ const targetPath = join8(TARGET_ROOT, relativePath);
2894
+ mkdirSync2(targetDir, { recursive: true });
2638
2895
  cpSync(fullPath, targetPath, { force: true });
2639
2896
  result.copied.push(relativePath);
2640
2897
  }
2641
2898
  }
2642
2899
  };
2643
2900
  const walkSource = (dir) => {
2644
- if (!existsSync4(dir)) return;
2645
- for (const item of readdirSync2(dir, { withFileTypes: true })) {
2646
- const fullPath = join7(dir, item.name);
2901
+ if (!existsSync5(dir)) return;
2902
+ for (const item of readdirSync3(dir, { withFileTypes: true })) {
2903
+ const fullPath = join8(dir, item.name);
2647
2904
  const relativePath = relative(SOURCE_ROOT, fullPath);
2648
2905
  if (shouldSkipMergePath(relativePath)) continue;
2649
2906
  if (item.isDirectory()) {
@@ -2651,10 +2908,10 @@ function mergeWorkspace(workspacePath) {
2651
2908
  continue;
2652
2909
  }
2653
2910
  if (!item.isFile()) continue;
2654
- const wsPath = join7(workspacePath, relativePath);
2655
- if (!existsSync4(wsPath)) {
2656
- const targetPath = join7(TARGET_ROOT, relativePath);
2657
- if (existsSync4(targetPath)) {
2911
+ const wsPath = join8(workspacePath, relativePath);
2912
+ if (!existsSync5(wsPath)) {
2913
+ const targetPath = join8(TARGET_ROOT, relativePath);
2914
+ if (existsSync5(targetPath)) {
2658
2915
  if (isConflict(relativePath)) {
2659
2916
  result.conflicts.push(relativePath);
2660
2917
  } else {
@@ -2976,16 +3233,16 @@ async function runCommandWithTimeout(command, workspacePath, issue, config, prom
2976
3233
  };
2977
3234
  for (const [key, value] of Object.entries(extraEnv)) {
2978
3235
  if (value.length > 4e3) {
2979
- const valFile = join7(workspacePath, `${key.toLowerCase()}.txt`);
2980
- writeFileSync2(valFile, value, "utf8");
3236
+ const valFile = join8(workspacePath, `${key.toLowerCase()}.txt`);
3237
+ writeFileSync3(valFile, value, "utf8");
2981
3238
  allVars[`${key}_FILE`] = valFile;
2982
3239
  } else {
2983
3240
  allVars[key] = value;
2984
3241
  }
2985
3242
  }
2986
- const envFilePath = join7(workspacePath, ".fifony-env.sh");
3243
+ const envFilePath = join8(workspacePath, ".fifony-env.sh");
2987
3244
  const envFileLines = Object.entries(allVars).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join("\n");
2988
- writeFileSync2(envFilePath, envFileLines, "utf8");
3245
+ writeFileSync3(envFilePath, envFileLines, "utf8");
2989
3246
  const wrappedCommand = `. "${envFilePath}" && ${command}`;
2990
3247
  const child = spawn2(wrappedCommand, {
2991
3248
  shell: true,
@@ -2998,10 +3255,11 @@ async function runCommandWithTimeout(command, workspacePath, issue, config, prom
2998
3255
  if (child.stdin) {
2999
3256
  child.stdin.end();
3000
3257
  }
3001
- const pidFile = join7(workspacePath, "fifony-agent.pid");
3258
+ const pidFile = join8(workspacePath, "fifony-agent.pid");
3002
3259
  const pid = child.pid;
3003
3260
  if (pid) {
3004
- writeFileSync2(pidFile, JSON.stringify({
3261
+ logger.debug({ issueId: issue.id, pid, command: command.slice(0, 120), cwd: workspacePath }, "[Agent] Process spawned");
3262
+ writeFileSync3(pidFile, JSON.stringify({
3005
3263
  pid,
3006
3264
  issueId: issue.id,
3007
3265
  startedAt: new Date(started).toISOString(),
@@ -3011,8 +3269,8 @@ async function runCommandWithTimeout(command, workspacePath, issue, config, prom
3011
3269
  let output = "";
3012
3270
  let timedOut = false;
3013
3271
  let outputBytes = 0;
3014
- const liveLogFile = join7(workspacePath, "fifony-live-output.log");
3015
- writeFileSync2(liveLogFile, "", "utf8");
3272
+ const liveLogFile = join8(workspacePath, "fifony-live-output.log");
3273
+ writeFileSync3(liveLogFile, "", "utf8");
3016
3274
  const onChunk = (chunk) => {
3017
3275
  const text = String(chunk);
3018
3276
  output = appendFileTail(output, text, config.logLinesTail);
@@ -3077,7 +3335,7 @@ async function runHook(command, workspacePath, issue, hookName, extraEnv = {}) {
3077
3335
  retryDelayMs: 0,
3078
3336
  staleInProgressTimeoutMs: 0,
3079
3337
  logLinesTail: 12e3,
3080
- agentProvider: normalizeAgentProvider(env5.FIFONY_AGENT_PROVIDER ?? "codex"),
3338
+ agentProvider: normalizeAgentProvider(env6.FIFONY_AGENT_PROVIDER ?? "codex"),
3081
3339
  agentCommand: command,
3082
3340
  maxTurns: 1,
3083
3341
  runMode: "filesystem"
@@ -3088,8 +3346,8 @@ async function runHook(command, workspacePath, issue, hookName, extraEnv = {}) {
3088
3346
  }
3089
3347
  async function cleanWorkspace(issueId, workflowDefinition) {
3090
3348
  const safeId = idToSafePath(issueId);
3091
- const workspacePath = join7(WORKSPACE_ROOT, safeId);
3092
- if (!existsSync4(workspacePath)) return;
3349
+ const workspacePath = join8(WORKSPACE_ROOT, safeId);
3350
+ if (!existsSync5(workspacePath)) return;
3093
3351
  if (workflowDefinition?.beforeRemoveHook) {
3094
3352
  try {
3095
3353
  const dummyIssue = { id: issueId, identifier: issueId };
@@ -3107,25 +3365,30 @@ async function cleanWorkspace(issueId, workflowDefinition) {
3107
3365
  }
3108
3366
  async function prepareWorkspace(issue, workflowDefinition) {
3109
3367
  const safeId = idToSafePath(issue.id);
3110
- const workspaceRoot = join7(WORKSPACE_ROOT, safeId);
3111
- const createdNow = !existsSync4(workspaceRoot);
3368
+ const workspaceRoot = join8(WORKSPACE_ROOT, safeId);
3369
+ const createdNow = !existsSync5(workspaceRoot);
3112
3370
  if (createdNow) {
3113
- mkdirSync(workspaceRoot, { recursive: true });
3371
+ logger.debug({ issueId: issue.id, identifier: issue.identifier, workspacePath: workspaceRoot }, "[Agent] Creating new workspace");
3372
+ mkdirSync2(workspaceRoot, { recursive: true });
3114
3373
  if (workflowDefinition?.afterCreateHook) {
3115
3374
  await runHook(workflowDefinition.afterCreateHook, workspaceRoot, issue, "after_create");
3116
3375
  } else {
3376
+ await ensureSourceReady();
3117
3377
  cpSync(SOURCE_ROOT, workspaceRoot, {
3118
3378
  recursive: true,
3119
3379
  force: true,
3120
3380
  filter: (sourcePath) => !sourcePath.startsWith(WORKSPACE_ROOT)
3121
3381
  });
3122
3382
  }
3383
+ logger.debug({ issueId: issue.id, workspacePath: workspaceRoot }, "[Agent] Workspace created");
3384
+ } else {
3385
+ logger.debug({ issueId: issue.id, workspacePath: workspaceRoot }, "[Agent] Reusing existing workspace");
3123
3386
  }
3124
- const metaPath = join7(workspaceRoot, "fifony-issue.json");
3387
+ const metaPath = join8(workspaceRoot, "fifony-issue.json");
3125
3388
  const promptText = await buildPrompt(issue, workflowDefinition);
3126
- const promptFile = join7(workspaceRoot, "fifony-prompt.md");
3127
- writeFileSync2(metaPath, JSON.stringify({ ...issue, runtimeSource: SOURCE_ROOT, bootstrapAt: now() }, null, 2), "utf8");
3128
- writeFileSync2(promptFile, `${promptText}
3389
+ const promptFile = join8(workspaceRoot, "fifony-prompt.md");
3390
+ writeFileSync3(metaPath, JSON.stringify({ ...issue, runtimeSource: SOURCE_ROOT, bootstrapAt: now() }, null, 2), "utf8");
3391
+ writeFileSync3(promptFile, `${promptText}
3129
3392
  `, "utf8");
3130
3393
  issue.workspacePath = workspaceRoot;
3131
3394
  issue.workspacePreparedAt = now();
@@ -3142,8 +3405,9 @@ async function runAgentSession(state, issue, provider, cycle, workspacePath, bas
3142
3405
  let nextPrompt = session.nextPrompt;
3143
3406
  let lastCode = session.lastCode;
3144
3407
  let lastOutput = session.lastOutput;
3145
- const resultFile = join7(workspacePath, `fifony-result-${provider.role}-${provider.provider}.json`);
3408
+ const resultFile = join8(workspacePath, `fifony-result-${provider.role}-${provider.provider}.json`);
3146
3409
  if (session.status === "done" && session.turns.length > 0) {
3410
+ logger.debug({ issueId: issue.id, identifier: issue.identifier, provider: provider.provider, role: provider.role }, "[Agent] Session already completed, returning cached result");
3147
3411
  return { success: true, blocked: false, continueRequested: false, code: session.lastCode, output: session.lastOutput, turns: session.turns.length };
3148
3412
  }
3149
3413
  const turnIndex = session.turns.length + 1;
@@ -3155,14 +3419,15 @@ Agent requested additional turns beyond configured limit (${maxTurns}).`, state.
3155
3419
  return { success: false, blocked: true, continueRequested: false, code: lastCode, output: session.lastOutput, turns: session.turns.length };
3156
3420
  }
3157
3421
  const turnPrompt = await buildTurnPrompt(issue, basePromptText, previousOutput, turnIndex, maxTurns, nextPrompt);
3158
- const turnPromptFile = turnIndex === 1 ? basePromptFile : join7(workspacePath, `fifony-turn-${String(turnIndex).padStart(2, "0")}.md`);
3159
- if (turnIndex > 1) writeFileSync2(turnPromptFile, `${turnPrompt}
3422
+ const turnPromptFile = turnIndex === 1 ? basePromptFile : join8(workspacePath, `fifony-turn-${String(turnIndex).padStart(2, "0")}.md`);
3423
+ if (turnIndex > 1) writeFileSync3(turnPromptFile, `${turnPrompt}
3160
3424
  `, "utf8");
3161
3425
  session.status = "running";
3162
3426
  session.lastPrompt = turnPrompt;
3163
3427
  session.lastPromptFile = turnPromptFile;
3164
3428
  session.maxTurns = maxTurns;
3165
3429
  await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
3430
+ logger.info({ issueId: issue.id, identifier: issue.identifier, turn: turnIndex, maxTurns, provider: provider.provider, role: provider.role, cycle, command: provider.command.slice(0, 120) }, "[Agent] Spawning agent command");
3166
3431
  const turnStartedAt = now();
3167
3432
  const turnEnv = {
3168
3433
  FIFONY_AGENT_PROVIDER: provider.provider,
@@ -3195,6 +3460,7 @@ Agent requested additional turns beyond configured limit (${maxTurns}).`, state.
3195
3460
  FIFONY_PRESERVE_RESULT_FILE: "1"
3196
3461
  });
3197
3462
  }
3463
+ logger.info({ issueId: issue.id, identifier: issue.identifier, turn: turnIndex, exitCode: turnResult.code, success: turnResult.success, outputBytes: turnResult.output.length }, "[Agent] Agent command finished");
3198
3464
  const directive = readAgentDirective(workspacePath, turnResult.output, turnResult.success);
3199
3465
  lastCode = turnResult.code;
3200
3466
  lastOutput = turnResult.output;
@@ -3240,20 +3506,24 @@ Agent requested additional turns beyond configured limit (${maxTurns}).`, state.
3240
3506
  const directiveSummary = directive.summary ? ` ${directive.summary}` : "";
3241
3507
  addEvent(state, issue.id, "runner", `Turn ${turnIndex}/${maxTurns} finished with status ${directive.status}.${directiveSummary}`.trim());
3242
3508
  if (!turnResult.success || directive.status === "failed") {
3509
+ logger.info({ issueId: issue.id, identifier: issue.identifier, turn: turnIndex, directiveStatus: directive.status, exitCode: lastCode }, "[Agent] Session turn failed");
3243
3510
  session.status = "failed";
3244
3511
  await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
3245
3512
  return { success: false, blocked: false, continueRequested: false, code: lastCode, output: lastOutput, turns: turnIndex };
3246
3513
  }
3247
3514
  if (directive.status === "blocked") {
3515
+ logger.info({ issueId: issue.id, identifier: issue.identifier, turn: turnIndex }, "[Agent] Session turn blocked \u2014 manual intervention requested");
3248
3516
  session.status = "blocked";
3249
3517
  await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
3250
3518
  return { success: false, blocked: true, continueRequested: false, code: lastCode, output: lastOutput, turns: turnIndex };
3251
3519
  }
3252
3520
  if (directive.status === "continue") {
3521
+ logger.info({ issueId: issue.id, identifier: issue.identifier, turn: turnIndex, maxTurns }, "[Agent] Session requests continuation");
3253
3522
  session.status = "running";
3254
3523
  await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
3255
3524
  return { success: false, blocked: false, continueRequested: true, code: lastCode, output: lastOutput, turns: turnIndex };
3256
3525
  }
3526
+ logger.info({ issueId: issue.id, identifier: issue.identifier, turn: turnIndex }, "[Agent] Session completed successfully");
3257
3527
  session.status = "done";
3258
3528
  await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
3259
3529
  return { success: true, blocked: false, continueRequested: false, code: lastCode, output: lastOutput, turns: turnIndex };
@@ -3261,13 +3531,14 @@ Agent requested additional turns beyond configured limit (${maxTurns}).`, state.
3261
3531
  async function runAgentPipeline(state, issue, workspacePath, basePromptText, basePromptFile, workflowDefinition, workflowConfig) {
3262
3532
  const providers = getEffectiveAgentProviders(state, issue, workflowDefinition, workflowConfig);
3263
3533
  const attempt = issue.attempts + 1;
3534
+ logger.debug({ issueId: issue.id, identifier: issue.identifier, attempt, providers: providers.map((p) => `${p.role}:${p.provider}`) }, "[Agent] Starting pipeline");
3264
3535
  const { pipeline, key: pipelineFile } = await loadAgentPipelineState(issue, attempt, providers);
3265
3536
  const activeProvider = providers[clamp(pipeline.activeIndex, 0, Math.max(0, providers.length - 1))];
3266
3537
  const executorIndex = providers.findIndex((provider) => provider.role === "executor");
3267
3538
  const skills = discoverSkills(workspacePath);
3268
3539
  const skillContext = buildSkillContext(skills);
3269
3540
  if (skillContext) {
3270
- writeFileSync2(join7(workspacePath, "fifony-skills.md"), skillContext, "utf8");
3541
+ writeFileSync3(join8(workspacePath, "fifony-skills.md"), skillContext, "utf8");
3271
3542
  }
3272
3543
  const compiled = await compileExecution(issue, activeProvider, state.config, workspacePath, skillContext);
3273
3544
  let providerPrompt;
@@ -3283,9 +3554,9 @@ async function runAgentPipeline(state, issue, workspacePath, basePromptText, bas
3283
3554
  `Plan compiled for ${compiled.meta.adapter}: effort=${compiled.meta.reasoningEffort}, skills=[${compiled.meta.skillsActivated.join(",")}], subagents=[${compiled.meta.subagentsRequested.join(",")}].`
3284
3555
  );
3285
3556
  if (Object.keys(compiled.env).length > 0) {
3286
- const envFile = join7(workspacePath, ".fifony-compiled-env.sh");
3557
+ const envFile = join8(workspacePath, ".fifony-compiled-env.sh");
3287
3558
  const envLines = Object.entries(compiled.env).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join("\n");
3288
- writeFileSync2(envFile, envLines, "utf8");
3559
+ writeFileSync3(envFile, envLines, "utf8");
3289
3560
  }
3290
3561
  } else {
3291
3562
  providerPrompt = await buildProviderBasePrompt(activeProvider, issue, basePromptText, workspacePath, skillContext);
@@ -3333,6 +3604,7 @@ async function runIssueOnce(state, issue, running, workflowDefinition) {
3333
3604
  const startTs = Date.now();
3334
3605
  const isReview = issue.state === "In Review";
3335
3606
  const isResuming = issue.state === "Running" || issue.state === "Interrupted";
3607
+ logger.info({ issueId: issue.id, identifier: issue.identifier, state: issue.state, isReview, isResuming, attempt: issue.attempts + 1, maxAttempts: issue.maxAttempts }, "[Agent] Starting issue execution");
3336
3608
  running.add(issue.id);
3337
3609
  state.metrics.activeWorkers += 1;
3338
3610
  issue.startedAt = issue.startedAt ?? now();
@@ -3393,8 +3665,8 @@ async function runIssueOnce(state, issue, running, workflowDefinition) {
3393
3665
  }
3394
3666
  const compiled = await compileReview(issue, reviewer, workspacePath, diffSummary);
3395
3667
  const effectiveReviewer = { ...reviewer, command: compiled.command || reviewer.command };
3396
- const reviewPromptFile = join7(workspacePath, "fifony-review-prompt.md");
3397
- writeFileSync2(reviewPromptFile, `${compiled.prompt}
3668
+ const reviewPromptFile = join8(workspacePath, "fifony-review-prompt.md");
3669
+ writeFileSync3(reviewPromptFile, `${compiled.prompt}
3398
3670
  `, "utf8");
3399
3671
  state._workflowDefinition = workflowDefinition;
3400
3672
  const reviewResult = await runAgentSession(state, issue, effectiveReviewer, 1, workspacePath, compiled.prompt, reviewPromptFile);
@@ -3510,7 +3782,10 @@ async function runIssueOnce(state, issue, running, workflowDefinition) {
3510
3782
  addEvent(state, issue.id, "error", `Issue ${issue.identifier} blocked after unexpected failure.`);
3511
3783
  }
3512
3784
  } finally {
3785
+ const elapsedMs = Date.now() - startTs;
3786
+ logger.info({ issueId: issue.id, identifier: issue.identifier, finalState: issue.state, elapsedMs, attempts: issue.attempts }, "[Agent] Issue execution finished");
3513
3787
  issue.updatedAt = now();
3788
+ markIssueDirty(issue.id);
3514
3789
  state.metrics.activeWorkers = Math.max(state.metrics.activeWorkers - 1, 0);
3515
3790
  running.delete(issue.id);
3516
3791
  state.metrics = computeMetrics(state.issues);
@@ -3520,7 +3795,7 @@ async function runIssueOnce(state, issue, running, workflowDefinition) {
3520
3795
  }
3521
3796
  }
3522
3797
 
3523
- // src/runtime/resources/issues.resource.ts
3798
+ // src/agent/resources/issues.resource.ts
3524
3799
  function getIssueId(c) {
3525
3800
  if (!c || typeof c !== "object" || !("req" in c) || !c.req || typeof c.req !== "object") {
3526
3801
  return null;
@@ -3746,7 +4021,7 @@ var issues_resource_default = {
3746
4021
  }
3747
4022
  };
3748
4023
 
3749
- // src/runtime/resources/events.resource.ts
4024
+ // src/agent/resources/events.resource.ts
3750
4025
  var events_resource_default = {
3751
4026
  name: S3DB_EVENT_RESOURCE,
3752
4027
  attributes: {
@@ -3772,7 +4047,7 @@ var events_resource_default = {
3772
4047
  }
3773
4048
  };
3774
4049
 
3775
- // src/runtime/resources/settings.resource.ts
4050
+ // src/agent/resources/settings.resource.ts
3776
4051
  var settings_resource_default = {
3777
4052
  name: S3DB_SETTINGS_RESOURCE,
3778
4053
  attributes: {
@@ -3794,7 +4069,7 @@ var settings_resource_default = {
3794
4069
  }
3795
4070
  };
3796
4071
 
3797
- // src/runtime/resources/agent-sessions.resource.ts
4072
+ // src/agent/resources/agent-sessions.resource.ts
3798
4073
  var agent_sessions_resource_default = {
3799
4074
  name: S3DB_AGENT_SESSION_RESOURCE,
3800
4075
  attributes: {
@@ -3824,7 +4099,7 @@ var agent_sessions_resource_default = {
3824
4099
  }
3825
4100
  };
3826
4101
 
3827
- // src/runtime/resources/agent-pipelines.resource.ts
4102
+ // src/agent/resources/agent-pipelines.resource.ts
3828
4103
  var agent_pipelines_resource_default = {
3829
4104
  name: S3DB_AGENT_PIPELINE_RESOURCE,
3830
4105
  attributes: {
@@ -3850,7 +4125,7 @@ var agent_pipelines_resource_default = {
3850
4125
  }
3851
4126
  };
3852
4127
 
3853
- // src/runtime/resources/index.ts
4128
+ // src/agent/resources/index.ts
3854
4129
  var NATIVE_RESOURCE_CONFIGS = [
3855
4130
  runtime_state_resource_default,
3856
4131
  issues_resource_default,
@@ -3861,45 +4136,45 @@ var NATIVE_RESOURCE_CONFIGS = [
3861
4136
  ];
3862
4137
  var NATIVE_RESOURCE_NAMES = NATIVE_RESOURCE_CONFIGS.map((resource) => resource.name);
3863
4138
 
3864
- // src/runtime/providers-usage.ts
4139
+ // src/agent/providers-usage.ts
3865
4140
  import { execSync as execSync2 } from "child_process";
3866
- import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
3867
- import { join as join8 } from "path";
4141
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
4142
+ import { join as join9 } from "path";
3868
4143
  import { homedir as homedir4 } from "os";
3869
- import { env as env6 } from "process";
4144
+ import { env as env7 } from "process";
3870
4145
  function resolveCodexHomeCandidates() {
3871
4146
  const homePaths = /* @__PURE__ */ new Set([
3872
4147
  homedir4(),
3873
- env6.XDG_STATE_HOME?.trim() || "",
3874
- env6.XDG_DATA_HOME?.trim() || ""
4148
+ env7.XDG_STATE_HOME?.trim() || "",
4149
+ env7.XDG_DATA_HOME?.trim() || ""
3875
4150
  ]);
3876
- const sudoUser = env6.SUDO_USER?.trim();
4151
+ const sudoUser = env7.SUDO_USER?.trim();
3877
4152
  if (sudoUser && sudoUser !== "root") {
3878
4153
  homePaths.add(`/home/${sudoUser}`);
3879
4154
  }
3880
4155
  const direct = /* @__PURE__ */ new Set([
3881
- env6.CODEX_HOME?.trim() || ""
4156
+ env7.CODEX_HOME?.trim() || ""
3882
4157
  ]);
3883
4158
  const candidates = [...homePaths, ...direct].filter(Boolean).flatMap((candidate) => {
3884
4159
  if (candidate.endsWith("/.codex") || candidate.endsWith("/codex")) return [candidate];
3885
- return [join8(candidate, ".codex"), join8(candidate, "codex")];
4160
+ return [join9(candidate, ".codex"), join9(candidate, "codex")];
3886
4161
  });
3887
4162
  return [...new Set(candidates)];
3888
4163
  }
3889
4164
  function resolveCodexDir() {
3890
4165
  for (const candidate of resolveCodexHomeCandidates()) {
3891
- if (existsSync5(candidate)) {
4166
+ if (existsSync6(candidate)) {
3892
4167
  return candidate;
3893
4168
  }
3894
4169
  }
3895
4170
  return null;
3896
4171
  }
3897
4172
  function findLatestCodexDb(codexDir) {
3898
- const explicit = join8(codexDir, "state_5.sqlite");
3899
- if (existsSync5(explicit)) return explicit;
3900
- const candidates = readdirSync3(codexDir).filter((name) => name.startsWith("state_") && name.endsWith(".sqlite")).sort().reverse();
4173
+ const explicit = join9(codexDir, "state_5.sqlite");
4174
+ if (existsSync6(explicit)) return explicit;
4175
+ const candidates = readdirSync4(codexDir).filter((name) => name.startsWith("state_") && name.endsWith(".sqlite")).sort().reverse();
3901
4176
  if (candidates.length === 0) return null;
3902
- return join8(codexDir, candidates[0]);
4177
+ return join9(codexDir, candidates[0]);
3903
4178
  }
3904
4179
  function computeNextMonday() {
3905
4180
  const now2 = /* @__PURE__ */ new Date();
@@ -3936,15 +4211,15 @@ var CLAUDE_PLAN_LIMITS = {
3936
4211
  };
3937
4212
  function collectClaudeUsage() {
3938
4213
  const home = homedir4();
3939
- const claudeDir = join8(home, ".claude");
3940
- if (!existsSync5(claudeDir)) return null;
4214
+ const claudeDir = join9(home, ".claude");
4215
+ if (!existsSync6(claudeDir)) return null;
3941
4216
  let available = false;
3942
4217
  try {
3943
4218
  execSync2("which claude", { encoding: "utf8", timeout: 3e3 });
3944
4219
  available = true;
3945
4220
  } catch {
3946
4221
  }
3947
- const projectsDir = join8(claudeDir, "projects");
4222
+ const projectsDir = join9(claudeDir, "projects");
3948
4223
  let totalInputTokens = 0;
3949
4224
  let totalOutputTokens = 0;
3950
4225
  let totalSessions = 0;
@@ -3958,23 +4233,23 @@ function collectClaudeUsage() {
3958
4233
  const todayMs = todayStart.getTime();
3959
4234
  const weekStart = computeWeekStart();
3960
4235
  const weekMs = weekStart.getTime();
3961
- if (existsSync5(projectsDir)) {
4236
+ if (existsSync6(projectsDir)) {
3962
4237
  try {
3963
- const projectDirs = readdirSync3(projectsDir, { withFileTypes: true });
4238
+ const projectDirs = readdirSync4(projectsDir, { withFileTypes: true });
3964
4239
  for (const dir of projectDirs) {
3965
4240
  if (!dir.isDirectory()) continue;
3966
- const projectPath = join8(projectsDir, dir.name);
4241
+ const projectPath = join9(projectsDir, dir.name);
3967
4242
  let sessionFiles;
3968
4243
  try {
3969
- sessionFiles = readdirSync3(projectPath).filter((f) => f.endsWith(".jsonl"));
4244
+ sessionFiles = readdirSync4(projectPath).filter((f) => f.endsWith(".jsonl"));
3970
4245
  } catch {
3971
4246
  continue;
3972
4247
  }
3973
4248
  for (const file of sessionFiles) {
3974
- const filePath = join8(projectPath, file);
4249
+ const filePath = join9(projectPath, file);
3975
4250
  let content;
3976
4251
  try {
3977
- content = readFileSync4(filePath, "utf8");
4252
+ content = readFileSync5(filePath, "utf8");
3978
4253
  } catch {
3979
4254
  continue;
3980
4255
  }
@@ -4028,10 +4303,10 @@ function collectClaudeUsage() {
4028
4303
  ];
4029
4304
  let plan = "pro";
4030
4305
  let resetInfo = "Weekly reset (every Monday 00:00 UTC)";
4031
- const settingsPath = join8(claudeDir, "settings.json");
4032
- if (existsSync5(settingsPath)) {
4306
+ const settingsPath = join9(claudeDir, "settings.json");
4307
+ if (existsSync6(settingsPath)) {
4033
4308
  try {
4034
- const settings = JSON.parse(readFileSync4(settingsPath, "utf8"));
4309
+ const settings = JSON.parse(readFileSync5(settingsPath, "utf8"));
4035
4310
  if (settings.plan === "max" || settings.plan === "max5x") {
4036
4311
  plan = settings.plan;
4037
4312
  resetInfo = `Plan: ${settings.plan.toUpperCase()} \u2014 Weekly token limit resets every Monday 00:00 UTC`;
@@ -4069,11 +4344,11 @@ function collectCodexUsage() {
4069
4344
  } catch {
4070
4345
  }
4071
4346
  const models = [];
4072
- const modelsCachePath = join8(codexDir, "models_cache.json");
4347
+ const modelsCachePath = join9(codexDir, "models_cache.json");
4073
4348
  let currentModel = "";
4074
- if (existsSync5(modelsCachePath)) {
4349
+ if (existsSync6(modelsCachePath)) {
4075
4350
  try {
4076
- const cache = JSON.parse(readFileSync4(modelsCachePath, "utf8"));
4351
+ const cache = JSON.parse(readFileSync5(modelsCachePath, "utf8"));
4077
4352
  for (const m of cache.models || []) {
4078
4353
  models.push({
4079
4354
  slug: m.slug,
@@ -4084,10 +4359,10 @@ function collectCodexUsage() {
4084
4359
  } catch {
4085
4360
  }
4086
4361
  }
4087
- const configPath = join8(codexDir, "config.toml");
4088
- if (existsSync5(configPath)) {
4362
+ const configPath = join9(codexDir, "config.toml");
4363
+ if (existsSync6(configPath)) {
4089
4364
  try {
4090
- const configContent = readFileSync4(configPath, "utf8");
4365
+ const configContent = readFileSync5(configPath, "utf8");
4091
4366
  const modelMatch = configContent.match(/^model\s*=\s*"([^"]+)"/m);
4092
4367
  if (modelMatch) currentModel = modelMatch[1];
4093
4368
  } catch {
@@ -4177,8 +4452,16 @@ function collectProvidersUsage() {
4177
4452
  };
4178
4453
  }
4179
4454
 
4180
- // src/runtime/scheduler.ts
4455
+ // src/agent/scheduler.ts
4181
4456
  var shuttingDown = false;
4457
+ var lastPersistAt = 0;
4458
+ var PERSIST_DEBOUNCE_MS = 5e3;
4459
+ var schedulerWakeResolve = null;
4460
+ function wakeScheduler() {
4461
+ schedulerWakeResolve?.();
4462
+ }
4463
+ var IDLE_POLL_MS = 5e3;
4464
+ var ACTIVE_POLL_MS = 500;
4182
4465
  function installGracefulShutdown(state, running) {
4183
4466
  const handler = async (signal) => {
4184
4467
  if (shuttingDown) {
@@ -4190,9 +4473,11 @@ function installGracefulShutdown(state, running) {
4190
4473
  addEvent(state, void 0, "info", `Graceful shutdown initiated (${signal}).`);
4191
4474
  for (const issue of state.issues) {
4192
4475
  if (running.has(issue.id) && (issue.state === "Running" || issue.state === "In Review")) {
4193
- issue.state = "Interrupted";
4194
- issue.updatedAt = now();
4195
- issue.history.push(`[${issue.updatedAt}] Interrupted by ${signal} \u2014 will resume on next start.`);
4476
+ try {
4477
+ await transitionIssueState(issue, "Interrupted", `Interrupted by ${signal} \u2014 will resume on next start.`, { fallbackToLocal: true });
4478
+ } catch {
4479
+ logger.warn(`Could not transition issue ${issue.identifier} to Interrupted during shutdown.`);
4480
+ }
4196
4481
  addEvent(state, issue.id, "info", `Issue ${issue.identifier} interrupted by shutdown.`);
4197
4482
  }
4198
4483
  }
@@ -4272,10 +4557,14 @@ async function ensureNotStale(state, staleTimeoutMs) {
4272
4557
  const limit = Date.now() - staleTimeoutMs;
4273
4558
  for (const issue of state.issues) {
4274
4559
  if (EXECUTING_STATES.has(issue.state) && Date.parse(issue.updatedAt) < limit && !TERMINAL_STATES.has(issue.state) && !issueHasResumableSession(issue)) {
4560
+ const staleMinutes = Math.round((Date.now() - Date.parse(issue.updatedAt)) / 6e4);
4561
+ logger.info({ issueId: issue.id, identifier: issue.identifier, state: issue.state, updatedAt: issue.updatedAt }, "[Scheduler] Recovering stale issue");
4275
4562
  issue.attempts += 1;
4276
4563
  issue.nextRetryAt = getNextRetryAt(issue, state.config.retryDelayMs);
4277
4564
  issue.startedAt = void 0;
4565
+ markIssueDirty(issue.id);
4278
4566
  await transitionIssueState(issue, "Blocked", `Issue state auto-recovered from stale execution.`);
4567
+ addEvent(state, issue.id, "info", `Issue ${issue.identifier} was stale for over ${staleMinutes} minute(s) in ${issue.state} state, moved to Blocked for retry.`);
4279
4568
  }
4280
4569
  }
4281
4570
  }
@@ -4289,7 +4578,11 @@ function isPerStateFull(issue, state, running) {
4289
4578
  return count >= limit;
4290
4579
  }
4291
4580
  function pickNextIssues(state, running, workflowDefinition) {
4292
- return state.issues.filter((issue) => canRunIssue(issue, running, state) && !isPerStateFull(issue, state, running)).sort((a, b) => {
4581
+ const candidates = state.issues.filter((issue) => canRunIssue(issue, running, state) && !isPerStateFull(issue, state, running));
4582
+ if (candidates.length > 0) {
4583
+ logger.debug({ candidates: candidates.map((i) => ({ id: i.identifier, state: i.state, priority: i.priority })) }, "[Scheduler] Eligible candidates for dispatch");
4584
+ }
4585
+ return candidates.sort((a, b) => {
4293
4586
  const stateWeight = (c) => c.state === "Running" ? 0 : c.state === "Blocked" ? 2 : 1;
4294
4587
  const weightDiff = stateWeight(a) - stateWeight(b);
4295
4588
  if (weightDiff !== 0) return weightDiff;
@@ -4341,15 +4634,29 @@ async function scheduler(state, running, runForever, workflowDefinition) {
4341
4634
  } else {
4342
4635
  const ready = pickNextIssues(state, running, workflowDefinition);
4343
4636
  const slots = state.config.workerConcurrency - running.size;
4344
- if (slots > 0) {
4637
+ if (slots > 0 && ready.length > 0) {
4345
4638
  const next = ready.slice(0, Math.max(0, slots));
4639
+ logger.debug({ slots, readyCount: ready.length, dispatching: next.map((i) => i.identifier) }, "[Scheduler] Dispatching issues");
4346
4640
  await Promise.all(next.map((issue) => runIssueOnce(state, issue, running, workflowDefinition)));
4641
+ } else if (ready.length > 0 && slots <= 0) {
4642
+ logger.debug({ runningCount: running.size, readyCount: ready.length, concurrency: state.config.workerConcurrency }, "[Scheduler] No slots available, waiting");
4347
4643
  }
4348
4644
  }
4349
4645
  state.updatedAt = now();
4350
- await persistState(state);
4351
- logger.debug("Scheduler tick completed.");
4352
- await sleep(state.config.pollIntervalMs);
4646
+ const shouldPersist = hasDirtyState() || Date.now() - lastPersistAt > PERSIST_DEBOUNCE_MS;
4647
+ if (shouldPersist) {
4648
+ await persistState(state);
4649
+ lastPersistAt = Date.now();
4650
+ }
4651
+ logger.debug({ runningCount: running.size, issueCount: state.issues.length, dirty: hasDirtyState() }, "[Scheduler] Tick completed");
4652
+ const effectivePoll = running.size > 0 ? ACTIVE_POLL_MS : IDLE_POLL_MS;
4653
+ await Promise.race([
4654
+ sleep(effectivePoll),
4655
+ new Promise((resolve4) => {
4656
+ schedulerWakeResolve = resolve4;
4657
+ })
4658
+ ]);
4659
+ schedulerWakeResolve = null;
4353
4660
  }
4354
4661
  return;
4355
4662
  }
@@ -4366,11 +4673,16 @@ async function scheduler(state, running, runForever, workflowDefinition) {
4366
4673
  const next = ready.slice(0, Math.max(0, slots));
4367
4674
  if (next.length === 0 && running.size === 0) {
4368
4675
  if (state.issues.some((issue) => issue.state === "Blocked" && issue.nextRetryAt && issue.attempts < issue.maxAttempts)) {
4676
+ logger.debug("[Scheduler] Batch mode: waiting for blocked issues to become eligible for retry");
4369
4677
  await sleep(state.config.pollIntervalMs);
4370
4678
  continue;
4371
4679
  }
4680
+ logger.debug("[Scheduler] Batch mode: no more work to do, exiting loop");
4372
4681
  break;
4373
4682
  }
4683
+ if (next.length > 0) {
4684
+ logger.debug({ slots, dispatching: next.map((i) => i.identifier) }, "[Scheduler] Batch mode: dispatching issues");
4685
+ }
4374
4686
  await Promise.all(next.map((issue) => runIssueOnce(state, issue, running, workflowDefinition)));
4375
4687
  state.updatedAt = now();
4376
4688
  await persistState(state);
@@ -4380,12 +4692,12 @@ async function scheduler(state, running, runForever, workflowDefinition) {
4380
4692
  }
4381
4693
  }
4382
4694
 
4383
- // src/runtime/issue-enhancer.ts
4384
- import { env as env7 } from "process";
4385
- import { existsSync as existsSync6, mkdtempSync, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
4695
+ // src/agent/issue-enhancer.ts
4696
+ import { env as env8 } from "process";
4697
+ import { existsSync as existsSync7, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
4386
4698
  import { spawn as spawn3 } from "child_process";
4387
4699
  import { tmpdir } from "os";
4388
- import { join as join9 } from "path";
4700
+ import { join as join10 } from "path";
4389
4701
  function getProviderCommand(provider, config, workflowDefinition) {
4390
4702
  const workflowConfig = workflowDefinition ? workflowDefinition.config : {};
4391
4703
  const codexCommand = getNestedString(getNestedRecord(workflowConfig, "codex"), "command");
@@ -4455,23 +4767,23 @@ function parseCandidate(raw, expectedField) {
4455
4767
  return "";
4456
4768
  }
4457
4769
  function readProviderOutput(resultFile, fallback) {
4458
- if (existsSync6(resultFile)) {
4770
+ if (existsSync7(resultFile)) {
4459
4771
  try {
4460
- return readFileSync5(resultFile, "utf8").trim();
4772
+ return readFileSync6(resultFile, "utf8").trim();
4461
4773
  } catch {
4462
4774
  }
4463
4775
  }
4464
4776
  return fallback;
4465
4777
  }
4466
4778
  async function runProviderCommand(command, provider, prompt, title, description, field, timeoutMs) {
4467
- const tempDir = mkdtempSync(join9(tmpdir(), "fifony-enhance-"));
4468
- const promptFile = join9(tempDir, "fifony-enhance-prompt.md");
4469
- const issuePayloadFile = join9(tempDir, "fifony-issue.json");
4470
- const resultFile = join9(tempDir, "fifony-result.txt");
4471
- const envFile = join9(tempDir, "fifony-enhance-env.sh");
4472
- writeFileSync3(promptFile, `${prompt}
4779
+ const tempDir = mkdtempSync(join10(tmpdir(), "fifony-enhance-"));
4780
+ const promptFile = join10(tempDir, "fifony-enhance-prompt.md");
4781
+ const issuePayloadFile = join10(tempDir, "fifony-issue.json");
4782
+ const resultFile = join10(tempDir, "fifony-result.txt");
4783
+ const envFile = join10(tempDir, "fifony-enhance-env.sh");
4784
+ writeFileSync4(promptFile, `${prompt}
4473
4785
  `, "utf8");
4474
- writeFileSync3(issuePayloadFile, JSON.stringify({ title, description, field }, null, 2), "utf8");
4786
+ writeFileSync4(issuePayloadFile, JSON.stringify({ title, description, field }, null, 2), "utf8");
4475
4787
  const envLines = [
4476
4788
  `export FIFONY_ISSUE_TITLE=${JSON.stringify(title)}`,
4477
4789
  `export FIFONY_ISSUE_DESCRIPTION=${JSON.stringify(description)}`,
@@ -4482,11 +4794,11 @@ async function runProviderCommand(command, provider, prompt, title, description,
4482
4794
  "export FIFONY_AGENT_PROVIDER=" + JSON.stringify(provider),
4483
4795
  "export FIFONY_RESULT_FILE=" + JSON.stringify(resultFile)
4484
4796
  ];
4485
- const processEnv = Object.entries(env7).map(([key, value]) => {
4797
+ const processEnv = Object.entries(env8).map(([key, value]) => {
4486
4798
  if (typeof value !== "string") return `export ${key}=${JSON.stringify("")}`;
4487
4799
  return `export ${key}=${JSON.stringify(value)}`;
4488
4800
  }).join("\n");
4489
- writeFileSync3(envFile, `${processEnv}
4801
+ writeFileSync4(envFile, `${processEnv}
4490
4802
  ${envLines.join("\n")}
4491
4803
  `, "utf8");
4492
4804
  const wrappedCommand = `. "${envFile}" && ${command}`;
@@ -4586,10 +4898,10 @@ async function enhanceIssueField(payload, config, workflowDefinition) {
4586
4898
  throw new Error(`Could not enhance issue field. ${errors.join(" | ")}`);
4587
4899
  }
4588
4900
 
4589
- // src/runtime/issue-planner.ts
4590
- import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync4, rmSync as rmSync3 } from "fs";
4901
+ // src/agent/issue-planner.ts
4902
+ import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync5, rmSync as rmSync3 } from "fs";
4591
4903
  import { spawn as spawn4 } from "child_process";
4592
- import { join as join10 } from "path";
4904
+ import { join as join11 } from "path";
4593
4905
  import { tmpdir as tmpdir2 } from "os";
4594
4906
  var PLANNING_SETTING_ID = "planning:active";
4595
4907
  function emptySession() {
@@ -4772,6 +5084,26 @@ function parsePlanOutput(raw) {
4772
5084
  } catch {
4773
5085
  }
4774
5086
  }
5087
+ const repaired = repairTruncatedJson(text);
5088
+ if (repaired) {
5089
+ try {
5090
+ const parsed = JSON.parse(repaired);
5091
+ const plan = tryBuildPlan(parsed);
5092
+ if (plan) {
5093
+ logger.warn("[Planner] Plan parsed from repaired truncated JSON output");
5094
+ return plan;
5095
+ }
5096
+ if (parsed?.structured_output && typeof parsed.structured_output === "object") {
5097
+ const innerPlan = tryBuildPlan(parsed.structured_output);
5098
+ if (innerPlan) {
5099
+ logger.warn("[Planner] Plan parsed from repaired truncated JSON envelope");
5100
+ return innerPlan;
5101
+ }
5102
+ }
5103
+ } catch {
5104
+ logger.debug("[Planner] JSON repair attempted but result still not parseable");
5105
+ }
5106
+ }
4775
5107
  return null;
4776
5108
  }
4777
5109
  async function savePlanningInput(title, description) {
@@ -4839,6 +5171,7 @@ function extractPlanTokenUsage(raw) {
4839
5171
  }
4840
5172
  async function generatePlan(title, description, config, workflowDefinition, options) {
4841
5173
  const fast = options?.fast ?? false;
5174
+ logger.info({ title: title.slice(0, 80), fast }, "[Planner] Starting plan generation");
4842
5175
  const providers = detectAvailableProviders();
4843
5176
  const available = providers.filter((p) => p.available).map((p) => p.name);
4844
5177
  let planStageProvider;
@@ -4859,6 +5192,7 @@ async function generatePlan(title, description, config, workflowDefinition, opti
4859
5192
  const effectiveEffort = fast ? "low" : planStageEffort || "medium";
4860
5193
  const command = getPlanCommand(preferred, planStageModel);
4861
5194
  if (!command) throw new Error(`No command configured for provider ${preferred}.`);
5195
+ logger.debug({ provider: preferred, model: planStageModel, effort: effectiveEffort, command: command.slice(0, 120) }, "[Planner] Provider selected for plan generation");
4862
5196
  const planStartMs = Date.now();
4863
5197
  const session = {
4864
5198
  title,
@@ -4876,25 +5210,24 @@ async function generatePlan(title, description, config, workflowDefinition, opti
4876
5210
  };
4877
5211
  await persistSession(session);
4878
5212
  const prompt = await buildPlanPrompt(title, description, fast);
4879
- const tempDir = mkdtempSync2(join10(tmpdir2(), "fifony-plan-"));
4880
- const promptFile = join10(tempDir, "fifony-plan-prompt.md");
4881
- const envFile = join10(tempDir, "fifony-plan-env.sh");
4882
- writeFileSync4(promptFile, `${prompt}
5213
+ const tempDir = mkdtempSync2(join11(tmpdir2(), "fifony-plan-"));
5214
+ const promptFile = join11(tempDir, "fifony-plan-prompt.md");
5215
+ writeFileSync5(promptFile, `${prompt}
4883
5216
  `, "utf8");
4884
- writeFileSync4(envFile, [
4885
- `export FIFONY_PROMPT_FILE=${JSON.stringify(promptFile)}`,
4886
- `export FIFONY_AGENT_PROVIDER=${JSON.stringify(preferred)}`
4887
- ].join("\n"), "utf8");
4888
- const wrappedCommand = `. "${envFile}" && ${command}`;
4889
5217
  let lastProgressPersist = 0;
4890
5218
  const PROGRESS_INTERVAL_MS = 2e3;
4891
5219
  const output = await new Promise((resolve4, reject) => {
4892
5220
  let stdout2 = "";
4893
- const child = spawn4(wrappedCommand, {
5221
+ const child = spawn4(command, {
4894
5222
  shell: true,
4895
5223
  cwd: tempDir,
4896
5224
  detached: true,
4897
- stdio: ["pipe", "pipe", "pipe"]
5225
+ stdio: ["pipe", "pipe", "pipe"],
5226
+ env: {
5227
+ ...process.env,
5228
+ FIFONY_PROMPT_FILE: promptFile,
5229
+ FIFONY_AGENT_PROVIDER: preferred
5230
+ }
4898
5231
  });
4899
5232
  child.unref();
4900
5233
  child.stdin?.end();
@@ -4943,13 +5276,17 @@ async function generatePlan(title, description, config, workflowDefinition, opti
4943
5276
  });
4944
5277
  });
4945
5278
  logger.info({ rawOutput: output.slice(0, 2e3) }, `Plan raw output from ${preferred}`);
5279
+ logger.debug({ outputLength: output.length }, "[Planner] Plan command completed, parsing output");
4946
5280
  const plan = parsePlanOutput(output);
4947
5281
  if (!plan) {
5282
+ const firstBrace = output.indexOf("{");
5283
+ const lastBrace = output.lastIndexOf("}");
5284
+ const truncationHint = firstBrace >= 0 && lastBrace < firstBrace ? " (JSON appears truncated \u2014 opening brace found but no matching close)" : firstBrace < 0 ? " (no JSON object found in output)" : "";
4948
5285
  session.status = "error";
4949
- session.error = `Could not parse plan. Output: ${output.slice(0, 500)}`;
5286
+ session.error = `Could not parse plan${truncationHint}. Output length: ${output.length} chars. Tail: ${output.slice(-200)}`;
4950
5287
  session.pid = null;
4951
5288
  await persistSession(session);
4952
- logger.error({ rawOutput: output.slice(0, 2e3) }, "Could not parse plan from AI output");
5289
+ logger.error({ rawOutput: output.slice(0, 2e3), outputLength: output.length, firstBrace, lastBrace }, "[Planner] Could not parse plan from AI output");
4953
5290
  throw new Error(session.error);
4954
5291
  }
4955
5292
  plan.provider = planStageModel ? `${preferred}/${planStageModel}` : preferred;
@@ -5013,23 +5350,22 @@ async function refinePlan(issue, feedback, config, workflowDefinition) {
5013
5350
  if (!command) throw new Error(`No command configured for provider ${preferred}.`);
5014
5351
  const refineStartMs = Date.now();
5015
5352
  const prompt = await buildRefinePrompt(issue.title, issue.description, issue.plan, feedback);
5016
- const tempDir = mkdtempSync2(join10(tmpdir2(), "fifony-refine-"));
5017
- const promptFile = join10(tempDir, "fifony-refine-prompt.md");
5018
- const envFile = join10(tempDir, "fifony-refine-env.sh");
5019
- writeFileSync4(promptFile, `${prompt}
5353
+ const tempDir = mkdtempSync2(join11(tmpdir2(), "fifony-refine-"));
5354
+ const promptFile = join11(tempDir, "fifony-refine-prompt.md");
5355
+ writeFileSync5(promptFile, `${prompt}
5020
5356
  `, "utf8");
5021
- writeFileSync4(envFile, [
5022
- `export FIFONY_PROMPT_FILE=${JSON.stringify(promptFile)}`,
5023
- `export FIFONY_AGENT_PROVIDER=${JSON.stringify(preferred)}`
5024
- ].join("\n"), "utf8");
5025
- const wrappedCommand = `. "${envFile}" && ${command}`;
5026
5357
  const output = await new Promise((resolve4, reject) => {
5027
5358
  let stdout2 = "";
5028
- const child = spawn4(wrappedCommand, {
5359
+ const child = spawn4(command, {
5029
5360
  shell: true,
5030
5361
  cwd: tempDir,
5031
5362
  detached: true,
5032
- stdio: ["pipe", "pipe", "pipe"]
5363
+ stdio: ["pipe", "pipe", "pipe"],
5364
+ env: {
5365
+ ...process.env,
5366
+ FIFONY_PROMPT_FILE: promptFile,
5367
+ FIFONY_AGENT_PROVIDER: preferred
5368
+ }
5033
5369
  });
5034
5370
  child.unref();
5035
5371
  child.stdin?.end();
@@ -5067,8 +5403,11 @@ async function refinePlan(issue, feedback, config, workflowDefinition) {
5067
5403
  logger.info({ rawOutput: output.slice(0, 2e3) }, `Refine raw output from ${preferred}`);
5068
5404
  const plan = parsePlanOutput(output);
5069
5405
  if (!plan) {
5070
- logger.error({ rawOutput: output.slice(0, 2e3) }, "Could not parse refined plan from AI output");
5071
- throw new Error(`Could not parse refined plan. Output: ${output.slice(0, 500)}`);
5406
+ const firstBrace = output.indexOf("{");
5407
+ const lastBrace = output.lastIndexOf("}");
5408
+ const truncationHint = firstBrace >= 0 && lastBrace < firstBrace ? " (JSON appears truncated \u2014 opening brace found but no matching close)" : firstBrace < 0 ? " (no JSON object found in output)" : "";
5409
+ logger.error({ rawOutput: output.slice(0, 2e3), outputLength: output.length, firstBrace, lastBrace }, "Could not parse refined plan from AI output");
5410
+ throw new Error(`Could not parse refined plan${truncationHint}. Output length: ${output.length} chars. Tail: ${output.slice(-200)}`);
5072
5411
  }
5073
5412
  plan.provider = planStageModel ? `${preferred}/${planStageModel}` : preferred;
5074
5413
  const existingRefinements = issue.plan.refinements ?? [];
@@ -5105,11 +5444,14 @@ function generatePlanInBackground(issue, config, workflowDefinition, callbacks,
5105
5444
  const { addEvent: addEvent2, persistState: persistState2, applyUsage, applySuggestions } = callbacks;
5106
5445
  const fast = options?.fast ?? false;
5107
5446
  issue.planningStatus = "planning";
5447
+ issue.planningStartedAt = now();
5108
5448
  issue.planningError = void 0;
5109
5449
  issue.updatedAt = now();
5450
+ addEvent2(issue.id, "info", `${fast ? "Fast plan" : "Plan"} generation starting for ${issue.identifier} (provider detection in progress).`);
5110
5451
  generatePlan(issue.title, issue.description, config, workflowDefinition, { fast }).then(async ({ plan, usage: usage2 }) => {
5111
5452
  issue.plan = plan;
5112
5453
  issue.planningStatus = "idle";
5454
+ issue.planningStartedAt = void 0;
5113
5455
  issue.planningError = void 0;
5114
5456
  issue.updatedAt = now();
5115
5457
  applyUsage(issue, usage2);
@@ -5121,6 +5463,7 @@ function generatePlanInBackground(issue, config, workflowDefinition, callbacks,
5121
5463
  await persistState2();
5122
5464
  }).catch(async (err) => {
5123
5465
  issue.planningStatus = "idle";
5466
+ issue.planningStartedAt = void 0;
5124
5467
  issue.planningError = err instanceof Error ? err.message : String(err);
5125
5468
  issue.updatedAt = now();
5126
5469
  addEvent2(issue.id, "error", `Plan generation failed for ${issue.identifier}: ${issue.planningError}`);
@@ -5131,11 +5474,15 @@ function generatePlanInBackground(issue, config, workflowDefinition, callbacks,
5131
5474
  function refinePlanInBackground(issue, feedback, config, workflowDefinition, callbacks) {
5132
5475
  const { addEvent: addEvent2, persistState: persistState2, applyUsage, applySuggestions } = callbacks;
5133
5476
  issue.planningStatus = "refining";
5477
+ issue.planningStartedAt = now();
5134
5478
  issue.planningError = void 0;
5135
5479
  issue.updatedAt = now();
5480
+ const feedbackSnippet = feedback.length > 60 ? `${feedback.slice(0, 57)}...` : feedback;
5481
+ addEvent2(issue.id, "info", `Plan refinement starting for ${issue.identifier}: "${feedbackSnippet}".`);
5136
5482
  refinePlan(issue, feedback, config, workflowDefinition).then(async ({ plan, usage: usage2 }) => {
5137
5483
  issue.plan = plan;
5138
5484
  issue.planningStatus = "idle";
5485
+ issue.planningStartedAt = void 0;
5139
5486
  issue.planningError = void 0;
5140
5487
  issue.updatedAt = now();
5141
5488
  applyUsage(issue, usage2);
@@ -5148,6 +5495,7 @@ function refinePlanInBackground(issue, feedback, config, workflowDefinition, cal
5148
5495
  await persistState2();
5149
5496
  }).catch(async (err) => {
5150
5497
  issue.planningStatus = "idle";
5498
+ issue.planningStartedAt = void 0;
5151
5499
  issue.planningError = err instanceof Error ? err.message : String(err);
5152
5500
  issue.updatedAt = now();
5153
5501
  addEvent2(issue.id, "error", `Plan refinement failed for ${issue.identifier}: ${issue.planningError}`);
@@ -5156,21 +5504,22 @@ function refinePlanInBackground(issue, feedback, config, workflowDefinition, cal
5156
5504
  });
5157
5505
  }
5158
5506
 
5159
- // src/runtime/project-scanner.ts
5507
+ // src/agent/project-scanner.ts
5160
5508
  import {
5161
- existsSync as existsSync7,
5509
+ existsSync as existsSync8,
5162
5510
  mkdtempSync as mkdtempSync3,
5163
- readdirSync as readdirSync4,
5164
- readFileSync as readFileSync6,
5511
+ readdirSync as readdirSync5,
5512
+ readFileSync as readFileSync7,
5165
5513
  rmSync as rmSync4,
5166
- writeFileSync as writeFileSync5
5514
+ writeFileSync as writeFileSync6
5167
5515
  } from "fs";
5168
- import { join as join11, basename as basename2 } from "path";
5516
+ import { join as join12, basename as basename2 } from "path";
5169
5517
  import { spawn as spawn5 } from "child_process";
5170
5518
  import { tmpdir as tmpdir3 } from "os";
5171
- import { env as env8 } from "process";
5519
+ import { env as env9 } from "process";
5520
+ import { createHash } from "crypto";
5172
5521
  function scanProjectFiles(targetRoot) {
5173
- const check = (rel) => existsSync7(join11(targetRoot, rel));
5522
+ const check = (rel) => existsSync8(join12(targetRoot, rel));
5174
5523
  const files = {
5175
5524
  claudeMd: check("CLAUDE.md"),
5176
5525
  claudeDir: check(".claude"),
@@ -5192,10 +5541,10 @@ function scanProjectFiles(targetRoot) {
5192
5541
  };
5193
5542
  const existingAgents = [];
5194
5543
  for (const agentDir of [".claude/agents", ".codex/agents"]) {
5195
- const fullPath = join11(targetRoot, agentDir);
5196
- if (!existsSync7(fullPath)) continue;
5544
+ const fullPath = join12(targetRoot, agentDir);
5545
+ if (!existsSync8(fullPath)) continue;
5197
5546
  try {
5198
- const entries = readdirSync4(fullPath);
5547
+ const entries = readdirSync5(fullPath);
5199
5548
  for (const entry of entries) {
5200
5549
  if (entry.endsWith(".md")) {
5201
5550
  existingAgents.push(basename2(entry, ".md"));
@@ -5206,13 +5555,13 @@ function scanProjectFiles(targetRoot) {
5206
5555
  }
5207
5556
  const existingSkills = [];
5208
5557
  for (const skillDir of [".claude/skills", ".codex/skills"]) {
5209
- const fullPath = join11(targetRoot, skillDir);
5210
- if (!existsSync7(fullPath)) continue;
5558
+ const fullPath = join12(targetRoot, skillDir);
5559
+ if (!existsSync8(fullPath)) continue;
5211
5560
  try {
5212
- const entries = readdirSync4(fullPath);
5561
+ const entries = readdirSync5(fullPath);
5213
5562
  for (const entry of entries) {
5214
- const skillFile = join11(fullPath, entry, "SKILL.md");
5215
- if (existsSync7(skillFile)) {
5563
+ const skillFile = join12(fullPath, entry, "SKILL.md");
5564
+ if (existsSync8(skillFile)) {
5216
5565
  existingSkills.push(entry);
5217
5566
  }
5218
5567
  }
@@ -5220,20 +5569,20 @@ function scanProjectFiles(targetRoot) {
5220
5569
  }
5221
5570
  }
5222
5571
  let readmeExcerpt = "";
5223
- const readmePath = join11(targetRoot, "README.md");
5224
- if (existsSync7(readmePath)) {
5572
+ const readmePath = join12(targetRoot, "README.md");
5573
+ if (existsSync8(readmePath)) {
5225
5574
  try {
5226
- const content = readFileSync6(readmePath, "utf8");
5575
+ const content = readFileSync7(readmePath, "utf8");
5227
5576
  readmeExcerpt = content.slice(0, 200).trim();
5228
5577
  } catch {
5229
5578
  }
5230
5579
  }
5231
5580
  let packageName = "";
5232
5581
  let packageDescription = "";
5233
- const pkgPath = join11(targetRoot, "package.json");
5234
- if (existsSync7(pkgPath)) {
5582
+ const pkgPath = join12(targetRoot, "package.json");
5583
+ if (existsSync8(pkgPath)) {
5235
5584
  try {
5236
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
5585
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
5237
5586
  packageName = typeof pkg.name === "string" ? pkg.name : "";
5238
5587
  packageDescription = typeof pkg.description === "string" ? pkg.description : "";
5239
5588
  } catch {
@@ -5275,39 +5624,39 @@ function buildFallbackAnalysis(targetRoot) {
5275
5624
  let description = "";
5276
5625
  let readmeExcerpt = "";
5277
5626
  for (const readmeFile of ["README.md", "README.rst", "README.txt", "README"]) {
5278
- const p = join11(targetRoot, readmeFile);
5279
- if (existsSync7(p)) {
5627
+ const p = join12(targetRoot, readmeFile);
5628
+ if (existsSync8(p)) {
5280
5629
  try {
5281
- readmeExcerpt = readFileSync6(p, "utf8").slice(0, 300).trim();
5630
+ readmeExcerpt = readFileSync7(p, "utf8").slice(0, 300).trim();
5282
5631
  break;
5283
5632
  } catch {
5284
5633
  }
5285
5634
  }
5286
5635
  }
5287
- const pkgPath = join11(targetRoot, "package.json");
5288
- if (existsSync7(pkgPath)) {
5636
+ const pkgPath = join12(targetRoot, "package.json");
5637
+ if (existsSync8(pkgPath)) {
5289
5638
  try {
5290
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
5639
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
5291
5640
  const name = typeof pkg.name === "string" ? pkg.name : "";
5292
5641
  const desc = typeof pkg.description === "string" ? pkg.description : "";
5293
5642
  if (desc) description = name ? `${name}: ${desc}` : desc;
5294
5643
  } catch {
5295
5644
  }
5296
5645
  }
5297
- const cargoPath = join11(targetRoot, "Cargo.toml");
5298
- if (!description && existsSync7(cargoPath)) {
5646
+ const cargoPath = join12(targetRoot, "Cargo.toml");
5647
+ if (!description && existsSync8(cargoPath)) {
5299
5648
  try {
5300
- const content = readFileSync6(cargoPath, "utf8");
5649
+ const content = readFileSync7(cargoPath, "utf8");
5301
5650
  const descMatch = content.match(/^description\s*=\s*"([^"]+)"/m);
5302
5651
  const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
5303
5652
  if (descMatch) description = nameMatch ? `${nameMatch[1]}: ${descMatch[1]}` : descMatch[1];
5304
5653
  } catch {
5305
5654
  }
5306
5655
  }
5307
- const pyprojectPath = join11(targetRoot, "pyproject.toml");
5308
- if (!description && existsSync7(pyprojectPath)) {
5656
+ const pyprojectPath = join12(targetRoot, "pyproject.toml");
5657
+ if (!description && existsSync8(pyprojectPath)) {
5309
5658
  try {
5310
- const content = readFileSync6(pyprojectPath, "utf8");
5659
+ const content = readFileSync7(pyprojectPath, "utf8");
5311
5660
  const descMatch = content.match(/^description\s*=\s*"([^"]+)"/m);
5312
5661
  const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
5313
5662
  if (descMatch) description = nameMatch ? `${nameMatch[1]}: ${descMatch[1]}` : descMatch[1];
@@ -5320,7 +5669,7 @@ function buildFallbackAnalysis(targetRoot) {
5320
5669
  let language = "unknown";
5321
5670
  const stack = [];
5322
5671
  for (const [file, signal] of Object.entries(BUILD_FILE_SIGNALS)) {
5323
- if (existsSync7(join11(targetRoot, file))) {
5672
+ if (existsSync8(join12(targetRoot, file))) {
5324
5673
  if (language === "unknown" && signal.language !== "unknown") {
5325
5674
  language = signal.language;
5326
5675
  }
@@ -5388,7 +5737,51 @@ function validateAnalysis(parsed) {
5388
5737
  source: "cli"
5389
5738
  };
5390
5739
  }
5391
- async function analyzeProjectWithCli(provider, targetRoot) {
5740
+ var ANALYSIS_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
5741
+ function computeProjectHash(targetRoot) {
5742
+ const buildFiles = Object.keys(BUILD_FILE_SIGNALS);
5743
+ const found = buildFiles.filter((f) => existsSync8(join12(targetRoot, f))).sort();
5744
+ return createHash("sha256").update(found.join(",")).digest("hex").slice(0, 16);
5745
+ }
5746
+ async function loadCachedAnalysis(targetRoot) {
5747
+ const resource = getSettingStateResource();
5748
+ if (!resource) return null;
5749
+ const hash = computeProjectHash(targetRoot);
5750
+ const key = `project-analysis:${hash}`;
5751
+ try {
5752
+ const record2 = await resource.get(key);
5753
+ if (!record2?.value) return null;
5754
+ const cached = record2.value;
5755
+ if (!cached.analysis || !cached.updatedAt) return null;
5756
+ if (Date.now() - Date.parse(cached.updatedAt) > ANALYSIS_CACHE_TTL_MS) return null;
5757
+ return cached.analysis;
5758
+ } catch {
5759
+ return null;
5760
+ }
5761
+ }
5762
+ async function saveCachedAnalysis(targetRoot, analysis) {
5763
+ const resource = getSettingStateResource();
5764
+ if (!resource) return;
5765
+ const hash = computeProjectHash(targetRoot);
5766
+ const key = `project-analysis:${hash}`;
5767
+ try {
5768
+ await resource.replace(key, {
5769
+ id: key,
5770
+ scope: "system",
5771
+ source: "detected",
5772
+ value: { analysis, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
5773
+ });
5774
+ } catch {
5775
+ }
5776
+ }
5777
+ async function analyzeProjectWithCli(provider, targetRoot, options) {
5778
+ if (!options?.forceRefresh) {
5779
+ const cached = await loadCachedAnalysis(targetRoot);
5780
+ if (cached) {
5781
+ logger.info("Using cached project analysis.");
5782
+ return cached;
5783
+ }
5784
+ }
5392
5785
  const normalizedProvider = provider.trim().toLowerCase();
5393
5786
  const providers = detectAvailableProviders();
5394
5787
  const providerInfo = providers.find((p) => p.name === normalizedProvider && p.available);
@@ -5399,12 +5792,12 @@ async function analyzeProjectWithCli(provider, targetRoot) {
5399
5792
  );
5400
5793
  return buildFallbackAnalysis(targetRoot);
5401
5794
  }
5402
- const tempDir = mkdtempSync3(join11(tmpdir3(), "fifony-scan-"));
5403
- const promptFile = join11(tempDir, "fifony-scan-prompt.txt");
5795
+ const tempDir = mkdtempSync3(join12(tmpdir3(), "fifony-scan-"));
5796
+ const promptFile = join12(tempDir, "fifony-scan-prompt.txt");
5404
5797
  const analysisPrompt = await renderPrompt("project-analysis");
5405
- writeFileSync5(promptFile, analysisPrompt, "utf8");
5798
+ writeFileSync6(promptFile, analysisPrompt, "utf8");
5406
5799
  const processEnv = {};
5407
- for (const [key, value] of Object.entries(env8)) {
5800
+ for (const [key, value] of Object.entries(env9)) {
5408
5801
  if (typeof value === "string") processEnv[key] = value;
5409
5802
  }
5410
5803
  processEnv.FIFONY_PROMPT_FILE = promptFile;
@@ -5473,6 +5866,7 @@ async function analyzeProjectWithCli(provider, targetRoot) {
5473
5866
  { provider: normalizedProvider, domains: analysis.domains, stack: analysis.stack },
5474
5867
  "CLI project analysis completed"
5475
5868
  );
5869
+ await saveCachedAnalysis(targetRoot, analysis);
5476
5870
  return analysis;
5477
5871
  }
5478
5872
  logger.warn(
@@ -5494,27 +5888,180 @@ async function analyzeProjectWithCli(provider, targetRoot) {
5494
5888
  }
5495
5889
  }
5496
5890
 
5497
- // src/runtime/catalog.ts
5498
- import { existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
5499
- import { join as join12, dirname as dirname2 } from "path";
5891
+ // src/agent/issue-scanner.ts
5892
+ import { execFileSync as execFileSync2 } from "child_process";
5893
+ var SCAN_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i;
5894
+ var EXCLUDE_DIRS = [
5895
+ "node_modules",
5896
+ ".git",
5897
+ ".fifony",
5898
+ "dist",
5899
+ "build",
5900
+ ".turbo",
5901
+ ".next",
5902
+ ".nuxt",
5903
+ "coverage",
5904
+ ".venv",
5905
+ "vendor",
5906
+ "tmp",
5907
+ "temp",
5908
+ "artifacts"
5909
+ ];
5910
+ function scanForTodos(targetRoot) {
5911
+ const excludeArgs = EXCLUDE_DIRS.flatMap((dir) => ["--exclude-dir", dir]);
5912
+ let output;
5913
+ try {
5914
+ output = execFileSync2("grep", [
5915
+ "-rn",
5916
+ "-E",
5917
+ "\\b(TODO|FIXME|HACK|XXX)\\b",
5918
+ ...excludeArgs,
5919
+ "--include=*.ts",
5920
+ "--include=*.tsx",
5921
+ "--include=*.js",
5922
+ "--include=*.jsx",
5923
+ "--include=*.py",
5924
+ "--include=*.rs",
5925
+ "--include=*.go",
5926
+ "--include=*.java",
5927
+ "--include=*.rb",
5928
+ "--include=*.php",
5929
+ "--include=*.cs",
5930
+ "--include=*.swift",
5931
+ "--include=*.kt",
5932
+ "--include=*.vue",
5933
+ "--include=*.svelte",
5934
+ targetRoot
5935
+ ], {
5936
+ encoding: "utf8",
5937
+ timeout: 15e3,
5938
+ maxBuffer: 5e6
5939
+ });
5940
+ } catch (error) {
5941
+ if (error.status === 1) return [];
5942
+ if (error.stdout) output = error.stdout;
5943
+ else {
5944
+ logger.warn(`TODO scan failed: ${String(error)}`);
5945
+ return [];
5946
+ }
5947
+ }
5948
+ const results = [];
5949
+ const lines = output.split("\n").filter(Boolean);
5950
+ for (const line of lines) {
5951
+ const match = line.match(/^(.+?):(\d+):(.+)$/);
5952
+ if (!match) continue;
5953
+ const [, file, lineNo, content] = match;
5954
+ const todoMatch = content.match(SCAN_PATTERN);
5955
+ if (!todoMatch) continue;
5956
+ const [, tag, text] = todoMatch;
5957
+ const source = tag.toLowerCase();
5958
+ const trimmedText = text.trim();
5959
+ if (!trimmedText || trimmedText.length < 5) continue;
5960
+ const relativePath = file.startsWith(targetRoot) ? file.slice(targetRoot.length + 1) : file;
5961
+ results.push({
5962
+ source: source === "xxx" ? "hack" : source,
5963
+ title: trimmedText.length > 120 ? `${trimmedText.slice(0, 117)}...` : trimmedText,
5964
+ file: relativePath,
5965
+ line: parseInt(lineNo, 10),
5966
+ context: content.trim()
5967
+ });
5968
+ }
5969
+ return results;
5970
+ }
5971
+ function categorizeScannedIssues(issues, workflowDefinition) {
5972
+ const options = getCapabilityRoutingOptions(workflowDefinition);
5973
+ return issues.map((issue) => {
5974
+ const resolution = resolveTaskCapabilities({
5975
+ id: `scan-${issue.file}:${issue.line}`,
5976
+ identifier: `${issue.source}:${issue.file}:${issue.line}`,
5977
+ title: issue.title,
5978
+ description: issue.context,
5979
+ labels: [issue.source],
5980
+ paths: [issue.file]
5981
+ }, options);
5982
+ return {
5983
+ ...issue,
5984
+ category: resolution.category,
5985
+ overlays: resolution.overlays,
5986
+ rationale: resolution.rationale,
5987
+ suggestedLabels: [
5988
+ issue.source,
5989
+ resolution.category ? `capability:${resolution.category}` : ""
5990
+ ].filter(Boolean),
5991
+ suggestedPaths: [issue.file]
5992
+ };
5993
+ });
5994
+ }
5995
+
5996
+ // src/agent/github-sync.ts
5997
+ import { execFile } from "child_process";
5998
+ async function fetchGitHubIssues(targetRoot) {
5999
+ return new Promise((resolve4) => {
6000
+ execFile(
6001
+ "gh",
6002
+ [
6003
+ "issue",
6004
+ "list",
6005
+ "--json",
6006
+ "number,title,body,labels,state,url",
6007
+ "--state",
6008
+ "open",
6009
+ "--limit",
6010
+ "50"
6011
+ ],
6012
+ {
6013
+ cwd: targetRoot,
6014
+ timeout: 15e3,
6015
+ maxBuffer: 2e6
6016
+ },
6017
+ (error, stdout2) => {
6018
+ if (error) {
6019
+ logger.warn(`Failed to fetch GitHub issues: ${String(error)}`);
6020
+ resolve4([]);
6021
+ return;
6022
+ }
6023
+ try {
6024
+ const issues = JSON.parse(stdout2.trim());
6025
+ const results = issues.map((issue) => ({
6026
+ source: "github",
6027
+ title: issue.title,
6028
+ file: "",
6029
+ line: 0,
6030
+ context: (issue.body || "").slice(0, 500),
6031
+ suggestedLabels: issue.labels.map((l) => l.name),
6032
+ suggestedPaths: []
6033
+ }));
6034
+ resolve4(results);
6035
+ } catch (parseError) {
6036
+ logger.warn(`Failed to parse GitHub issues: ${String(parseError)}`);
6037
+ resolve4([]);
6038
+ }
6039
+ }
6040
+ );
6041
+ });
6042
+ }
6043
+
6044
+ // src/agent/catalog.ts
6045
+ import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
6046
+ import { join as join13, dirname as dirname2 } from "path";
5500
6047
  import { fileURLToPath as fileURLToPath2 } from "url";
5501
6048
  var __filename2 = fileURLToPath2(import.meta.url);
5502
6049
  var __dirname2 = dirname2(__filename2);
5503
6050
  function resolveFixturePath(filename) {
5504
6051
  const candidates = [
5505
- join12(__dirname2, "..", "fixtures", filename),
5506
- join12(__dirname2, "../..", "src", "fixtures", filename),
5507
- join12(__dirname2, "../../..", "src", "fixtures", filename)
6052
+ join13(__dirname2, "..", "fixtures", filename),
6053
+ join13(__dirname2, "../..", "src", "fixtures", filename),
6054
+ join13(__dirname2, "../../..", "src", "fixtures", filename)
5508
6055
  ];
5509
6056
  for (const candidate of candidates) {
5510
- if (existsSync8(candidate)) return candidate;
6057
+ if (existsSync9(candidate)) return candidate;
5511
6058
  }
5512
6059
  return candidates[0];
5513
6060
  }
5514
6061
  function loadAgentCatalog() {
5515
6062
  try {
5516
6063
  const filePath = resolveFixturePath("agent-catalog.json");
5517
- const raw = readFileSync7(filePath, "utf8");
6064
+ const raw = readFileSync8(filePath, "utf8");
5518
6065
  return JSON.parse(raw);
5519
6066
  } catch (error) {
5520
6067
  logger.error({ err: error }, "Failed to load agent catalog");
@@ -5524,7 +6071,7 @@ function loadAgentCatalog() {
5524
6071
  function loadSkillCatalog() {
5525
6072
  try {
5526
6073
  const filePath = resolveFixturePath("skill-catalog.json");
5527
- const raw = readFileSync7(filePath, "utf8");
6074
+ const raw = readFileSync8(filePath, "utf8");
5528
6075
  return JSON.parse(raw);
5529
6076
  } catch (error) {
5530
6077
  logger.error({ err: error }, "Failed to load skill catalog");
@@ -5543,9 +6090,9 @@ function filterByDomains(catalog, domains) {
5543
6090
  function installAgents(targetRoot, agentNames, catalog) {
5544
6091
  const result = { installed: [], skipped: [], errors: [] };
5545
6092
  const catalogMap = new Map(catalog.map((entry) => [entry.name, entry]));
5546
- const agentsDir = join12(targetRoot, ".claude", "agents");
6093
+ const agentsDir = join13(targetRoot, ".claude", "agents");
5547
6094
  try {
5548
- mkdirSync2(agentsDir, { recursive: true });
6095
+ mkdirSync3(agentsDir, { recursive: true });
5549
6096
  } catch (error) {
5550
6097
  logger.error({ err: error, path: agentsDir }, "Failed to create agents directory");
5551
6098
  result.errors.push({ name: "_directory", error: `Failed to create ${agentsDir}` });
@@ -5557,13 +6104,13 @@ function installAgents(targetRoot, agentNames, catalog) {
5557
6104
  result.errors.push({ name, error: "Agent not found in catalog" });
5558
6105
  continue;
5559
6106
  }
5560
- const filePath = join12(agentsDir, `${name}.md`);
5561
- if (existsSync8(filePath)) {
6107
+ const filePath = join13(agentsDir, `${name}.md`);
6108
+ if (existsSync9(filePath)) {
5562
6109
  result.skipped.push(name);
5563
6110
  continue;
5564
6111
  }
5565
6112
  try {
5566
- writeFileSync6(filePath, entry.content, "utf8");
6113
+ writeFileSync7(filePath, entry.content, "utf8");
5567
6114
  result.installed.push(name);
5568
6115
  logger.info({ agent: name, path: filePath }, "Agent installed");
5569
6116
  } catch (error) {
@@ -5578,9 +6125,9 @@ function installAgents(targetRoot, agentNames, catalog) {
5578
6125
  function installSkills(targetRoot, skillNames, catalog) {
5579
6126
  const result = { installed: [], skipped: [], errors: [] };
5580
6127
  const catalogMap = new Map(catalog.map((entry) => [entry.name, entry]));
5581
- const skillsDir = join12(targetRoot, ".claude", "skills");
6128
+ const skillsDir = join13(targetRoot, ".claude", "skills");
5582
6129
  try {
5583
- mkdirSync2(skillsDir, { recursive: true });
6130
+ mkdirSync3(skillsDir, { recursive: true });
5584
6131
  } catch (error) {
5585
6132
  logger.error({ err: error, path: skillsDir }, "Failed to create skills directory");
5586
6133
  result.errors.push({ name: "_directory", error: `Failed to create ${skillsDir}` });
@@ -5592,16 +6139,16 @@ function installSkills(targetRoot, skillNames, catalog) {
5592
6139
  result.errors.push({ name, error: "Skill not found in catalog" });
5593
6140
  continue;
5594
6141
  }
5595
- const skillDir = join12(skillsDir, name);
5596
- const filePath = join12(skillDir, "SKILL.md");
5597
- if (existsSync8(filePath)) {
6142
+ const skillDir = join13(skillsDir, name);
6143
+ const filePath = join13(skillDir, "SKILL.md");
6144
+ if (existsSync9(filePath)) {
5598
6145
  result.skipped.push(name);
5599
6146
  continue;
5600
6147
  }
5601
6148
  try {
5602
- mkdirSync2(skillDir, { recursive: true });
6149
+ mkdirSync3(skillDir, { recursive: true });
5603
6150
  if (entry.installType === "bundled" && entry.content) {
5604
- writeFileSync6(filePath, entry.content, "utf8");
6151
+ writeFileSync7(filePath, entry.content, "utf8");
5605
6152
  } else {
5606
6153
  const referenceContent = [
5607
6154
  `# ${entry.displayName}`,
@@ -5613,7 +6160,7 @@ function installSkills(targetRoot, skillNames, catalog) {
5613
6160
  "",
5614
6161
  `> This skill references an external resource. Install it from the source above.`
5615
6162
  ].filter(Boolean).join("\n");
5616
- writeFileSync6(filePath, referenceContent, "utf8");
6163
+ writeFileSync7(filePath, referenceContent, "utf8");
5617
6164
  }
5618
6165
  result.installed.push(name);
5619
6166
  logger.info({ skill: name, path: filePath, type: entry.installType }, "Skill installed");
@@ -5627,7 +6174,8 @@ function installSkills(targetRoot, skillNames, catalog) {
5627
6174
  return result;
5628
6175
  }
5629
6176
 
5630
- // src/runtime/api-server.ts
6177
+ // src/agent/api-server.ts
6178
+ import { join as join14 } from "path";
5631
6179
  var wsClients = /* @__PURE__ */ new Map();
5632
6180
  var broadcastSeq = 0;
5633
6181
  var lastBroadcastIssueSnapshot = /* @__PURE__ */ new Map();
@@ -5646,6 +6194,7 @@ function sendToAllClients(data) {
5646
6194
  function broadcastToWebSocketClients(message) {
5647
6195
  if (wsClients.size === 0) return;
5648
6196
  broadcastSeq++;
6197
+ logger.debug({ seq: broadcastSeq, type: message.type, clientCount: wsClients.size }, "[WebSocket] Broadcasting state update");
5649
6198
  const issues = message.issues;
5650
6199
  if (issues && lastBroadcastIssueSnapshot.size > 0) {
5651
6200
  const currentIds = /* @__PURE__ */ new Set();
@@ -5693,6 +6242,7 @@ function broadcastToWebSocketClients(message) {
5693
6242
  }));
5694
6243
  }
5695
6244
  async function startApiServer(state, port, workflowDefinition) {
6245
+ logger.info({ port }, "[API] Starting API server");
5696
6246
  const stateDb2 = getStateDb();
5697
6247
  if (!stateDb2) {
5698
6248
  throw new Error("Cannot start API plugin before the database is initialized.");
@@ -5765,9 +6315,15 @@ async function startApiServer(state, port, workflowDefinition) {
5765
6315
  if (!issue) {
5766
6316
  return c.json({ ok: false, error: "Issue not found" }, 404);
5767
6317
  }
5768
- await updater(issue);
5769
- await persistState(state);
5770
- return c.json({ ok: true, issue });
6318
+ try {
6319
+ await updater(issue);
6320
+ await persistState(state);
6321
+ wakeScheduler();
6322
+ return c.json({ ok: true, issue });
6323
+ } catch (error) {
6324
+ logger.error({ err: error, issueId }, "[API] mutateIssueState failed");
6325
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
6326
+ }
5771
6327
  };
5772
6328
  const resourceConfigs = Object.fromEntries(
5773
6329
  NATIVE_RESOURCE_CONFIGS.map((resourceConfig) => [
@@ -5787,10 +6343,10 @@ async function startApiServer(state, port, workflowDefinition) {
5787
6343
  }
5788
6344
  setApiRuntimeContext(state, workflowDefinition);
5789
6345
  const serveTextFile = (filePath, contentType, cacheControl = "no-cache") => {
5790
- if (!existsSync9(filePath)) {
6346
+ if (!existsSync10(filePath)) {
5791
6347
  return new Response("Not found", { status: 404 });
5792
6348
  }
5793
- return new Response(readFileSync8(filePath), {
6349
+ return new Response(readFileSync9(filePath), {
5794
6350
  headers: {
5795
6351
  "content-type": contentType,
5796
6352
  "cache-control": cacheControl
@@ -5798,10 +6354,10 @@ async function startApiServer(state, port, workflowDefinition) {
5798
6354
  });
5799
6355
  };
5800
6356
  const serveAppShell = () => {
5801
- if (!existsSync9(FRONTEND_INDEX)) {
6357
+ if (!existsSync10(FRONTEND_INDEX)) {
5802
6358
  return new Response("Not found", { status: 404 });
5803
6359
  }
5804
- const html = readFileSync8(FRONTEND_INDEX, "utf8").replace('href="/assets/manifest.webmanifest"', 'href="/manifest.webmanifest"').replaceAll('href="/assets/icon.svg"', 'href="/icon.svg"');
6360
+ const html = readFileSync9(FRONTEND_INDEX, "utf8").replace('href="/assets/manifest.webmanifest"', 'href="/manifest.webmanifest"').replaceAll('href="/assets/icon.svg"', 'href="/icon.svg"');
5805
6361
  return new Response(html, {
5806
6362
  headers: {
5807
6363
  "content-type": "text/html; charset=utf-8",
@@ -5881,14 +6437,17 @@ async function startApiServer(state, port, workflowDefinition) {
5881
6437
  "GET /offline.html": () => serveTextFile(FRONTEND_OFFLINE_HTML, "text/html; charset=utf-8"),
5882
6438
  "GET /icon.svg": () => serveTextFile(FRONTEND_ICON_SVG, "image/svg+xml", "public, max-age=604800, immutable"),
5883
6439
  "GET /icon-maskable.svg": () => serveTextFile(FRONTEND_MASKABLE_ICON_SVG, "image/svg+xml", "public, max-age=604800, immutable"),
6440
+ "GET /onboarding": () => serveAppShell(),
5884
6441
  "GET /kanban": () => serveAppShell(),
5885
6442
  "GET /issues": () => serveAppShell(),
6443
+ "GET /discover": () => serveAppShell(),
5886
6444
  "GET /agents": () => serveAppShell(),
5887
6445
  "GET /settings": () => serveAppShell(),
5888
6446
  "GET /settings/general": () => serveAppShell(),
5889
6447
  "GET /settings/notifications": () => serveAppShell(),
5890
6448
  "GET /settings/workflow": () => serveAppShell(),
5891
6449
  "GET /settings/providers": () => serveAppShell(),
6450
+ "GET /api/health": (c) => c.json({ status: state.booting ? "booting" : "ready" }),
5892
6451
  "GET /api/state": async (c) => {
5893
6452
  const showAll = c.req.query("all") === "1";
5894
6453
  let issues = state.issues;
@@ -6037,6 +6596,7 @@ async function startApiServer(state, port, workflowDefinition) {
6037
6596
  const title = toStringValue(payload.title);
6038
6597
  const description = toStringValue(payload.description);
6039
6598
  if (!title) return c.json({ ok: false, error: "Title is required." }, 400);
6599
+ logger.info({ title: title.slice(0, 80) }, "[API] POST /api/planning/generate");
6040
6600
  const result = await generatePlan(title, description, state.config, workflowDefinition);
6041
6601
  return c.json({ ok: true, plan: result.plan, usage: result.usage });
6042
6602
  } catch (error) {
@@ -6064,13 +6624,16 @@ async function startApiServer(state, port, workflowDefinition) {
6064
6624
  "POST /api/issues/create": async (c) => {
6065
6625
  try {
6066
6626
  const payload = await c.req.json();
6627
+ logger.info({ title: toStringValue(payload.title, "").slice(0, 80) }, "[API] POST /api/issues/create");
6067
6628
  const issue = createIssueFromPayload(payload, state.issues, workflowDefinition);
6068
6629
  state.issues.push(issue);
6630
+ markIssueDirty(issue.id);
6069
6631
  addEvent(state, issue.id, "info", `Issue ${issue.identifier} created via API.`);
6070
6632
  if (issue.plan) {
6071
6633
  addEvent(state, issue.id, "info", `Plan: ${issue.plan.steps.length} steps, complexity: ${issue.plan.estimatedComplexity}.`);
6072
6634
  }
6073
6635
  await persistState(state);
6636
+ wakeScheduler();
6074
6637
  return c.json({ ok: true, issue }, 201);
6075
6638
  } catch (error) {
6076
6639
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
@@ -6111,14 +6674,17 @@ async function startApiServer(state, port, workflowDefinition) {
6111
6674
  }
6112
6675
  try {
6113
6676
  const payload = await c.req.json();
6677
+ logger.info({ issueId, identifier: issue.identifier, targetState: payload.state }, "[API] POST /api/issues/:id/state");
6114
6678
  await handleStatePatch(state, issue, payload);
6115
6679
  await persistState(state);
6680
+ wakeScheduler();
6116
6681
  return c.json({ ok: true, issue });
6117
6682
  } catch (error) {
6118
6683
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
6119
6684
  }
6120
6685
  },
6121
6686
  "POST /api/issues/:id/retry": async (c) => {
6687
+ logger.info({ issueId: parseIssue(c) }, "[API] POST /api/issues/:id/retry");
6122
6688
  return mutateIssueState(c, async (issue) => {
6123
6689
  if (TERMINAL_STATES.has(issue.state)) {
6124
6690
  await transitionIssueState(issue, "Todo", "Manual retry requested.");
@@ -6131,6 +6697,7 @@ async function startApiServer(state, port, workflowDefinition) {
6131
6697
  });
6132
6698
  },
6133
6699
  "POST /api/issues/:id/cancel": async (c) => {
6700
+ logger.info({ issueId: parseIssue(c) }, "[API] POST /api/issues/:id/cancel");
6134
6701
  return mutateIssueState(c, async (issue) => {
6135
6702
  await transitionIssueState(issue, "Cancelled", "Manual cancel requested.");
6136
6703
  addEvent(state, issue.id, "manual", `Manual cancel requested for ${issue.id}.`);
@@ -6156,6 +6723,7 @@ async function startApiServer(state, port, workflowDefinition) {
6156
6723
  });
6157
6724
  },
6158
6725
  "POST /api/issues/:id/approve": async (c) => {
6726
+ logger.info({ issueId: parseIssue(c) }, "[API] POST /api/issues/:id/approve");
6159
6727
  return mutateIssueState(c, async (issue) => {
6160
6728
  if (issue.state !== "Planning") {
6161
6729
  throw new Error(`Cannot approve issue in state ${issue.state}. Must be in Planning.`);
@@ -6165,13 +6733,14 @@ async function startApiServer(state, port, workflowDefinition) {
6165
6733
  });
6166
6734
  },
6167
6735
  "POST /api/issues/:id/merge": async (c) => {
6736
+ logger.info({ issueId: parseIssue(c) }, "[API] POST /api/issues/:id/merge");
6168
6737
  try {
6169
6738
  const issueId = parseIssue(c);
6170
6739
  if (!issueId) return c.json({ ok: false, error: "Issue id is required." }, 400);
6171
6740
  const issue = findIssue2(issueId);
6172
6741
  if (!issue) return c.json({ ok: false, error: "Issue not found." }, 404);
6173
6742
  const wp = issue.workspacePath;
6174
- if (!wp || !existsSync9(wp)) {
6743
+ if (!wp || !existsSync10(wp)) {
6175
6744
  return c.json({ ok: false, error: "No workspace found for this issue." }, 400);
6176
6745
  }
6177
6746
  const result = mergeWorkspace(wp);
@@ -6190,6 +6759,9 @@ async function startApiServer(state, port, workflowDefinition) {
6190
6759
  },
6191
6760
  "POST /api/issues/:id/plan/refine": async (c) => {
6192
6761
  return mutateIssueState(c, async (issue) => {
6762
+ if (issue.state !== "Planning") {
6763
+ throw new Error(`Cannot refine plan for issue in state ${issue.state}. Must be in Planning.`);
6764
+ }
6193
6765
  if (!issue.plan) {
6194
6766
  throw new Error("Issue has no plan to refine. Generate a plan first.");
6195
6767
  }
@@ -6239,10 +6811,10 @@ async function startApiServer(state, port, workflowDefinition) {
6239
6811
  const liveLog = wp ? `${wp}/fifony-live-output.log` : null;
6240
6812
  let logTail = "";
6241
6813
  let logSize = 0;
6242
- if (liveLog && existsSync9(liveLog)) {
6814
+ if (liveLog && existsSync10(liveLog)) {
6243
6815
  try {
6244
- const stat = statSync2(liveLog);
6245
- logSize = stat.size;
6816
+ const stat2 = statSync3(liveLog);
6817
+ logSize = stat2.size;
6246
6818
  const fd = openSync(liveLog, "r");
6247
6819
  const readSize = Math.min(logSize, 8192);
6248
6820
  const buf = Buffer.alloc(readSize);
@@ -6279,10 +6851,10 @@ async function startApiServer(state, port, workflowDefinition) {
6279
6851
  const issue = findIssue2(issueId);
6280
6852
  if (!issue) return c.json({ ok: false, error: "Issue not found." }, 404);
6281
6853
  const wp = issue.workspacePath;
6282
- if (!wp || !existsSync9(wp)) {
6854
+ if (!wp || !existsSync10(wp)) {
6283
6855
  return c.json({ ok: true, files: [], diff: "", message: "No workspace found." });
6284
6856
  }
6285
- if (!existsSync9(SOURCE_ROOT)) {
6857
+ if (!existsSync10(SOURCE_ROOT)) {
6286
6858
  return c.json({ ok: true, files: [], diff: "", message: "Source root not found." });
6287
6859
  }
6288
6860
  let raw = "";
@@ -6348,6 +6920,46 @@ async function startApiServer(state, port, workflowDefinition) {
6348
6920
  });
6349
6921
  return c.json({ events: events.slice(0, 200) });
6350
6922
  },
6923
+ // ── Onboarding: gitignore check ────────────────────────────────────
6924
+ "GET /api/gitignore/status": async (c) => {
6925
+ try {
6926
+ const gitignorePath = join14(TARGET_ROOT, ".gitignore");
6927
+ if (!existsSync10(gitignorePath)) {
6928
+ return c.json({ exists: false, hasFifony: false });
6929
+ }
6930
+ const content = readFileSync9(gitignorePath, "utf-8");
6931
+ const lines = content.split("\n").map((l) => l.trim());
6932
+ const hasFifony = lines.some((l) => l === ".fifony" || l === ".fifony/" || l === "/.fifony" || l === "/.fifony/");
6933
+ return c.json({ exists: true, hasFifony });
6934
+ } catch (error) {
6935
+ logger.error({ err: error }, "Failed to check .gitignore");
6936
+ return c.json({ exists: false, hasFifony: false, error: "Failed to check .gitignore" }, 500);
6937
+ }
6938
+ },
6939
+ "POST /api/gitignore/add": async (c) => {
6940
+ try {
6941
+ const gitignorePath = join14(TARGET_ROOT, ".gitignore");
6942
+ if (!existsSync10(gitignorePath)) {
6943
+ writeFileSync8(gitignorePath, "# Fifony state directory\n.fifony/\n", "utf-8");
6944
+ return c.json({ ok: true, created: true });
6945
+ }
6946
+ const content = readFileSync9(gitignorePath, "utf-8");
6947
+ const lines = content.split("\n").map((l) => l.trim());
6948
+ const hasFifony = lines.some((l) => l === ".fifony" || l === ".fifony/" || l === "/.fifony" || l === "/.fifony/");
6949
+ if (hasFifony) {
6950
+ return c.json({ ok: true, alreadyPresent: true });
6951
+ }
6952
+ const suffix = content.endsWith("\n") ? "" : "\n";
6953
+ appendFileSync2(gitignorePath, `${suffix}
6954
+ # Fifony state directory
6955
+ .fifony/
6956
+ `, "utf-8");
6957
+ return c.json({ ok: true, added: true });
6958
+ } catch (error) {
6959
+ logger.error({ err: error }, "Failed to update .gitignore");
6960
+ return c.json({ ok: false, error: "Failed to update .gitignore" }, 500);
6961
+ }
6962
+ },
6351
6963
  // ── Onboarding: project scanning & catalog ─────────────────────────
6352
6964
  "GET /api/scan/project": async (c) => {
6353
6965
  try {
@@ -6369,6 +6981,30 @@ async function startApiServer(state, port, workflowDefinition) {
6369
6981
  return c.json({ ok: false, error: "Failed to analyze project." }, 500);
6370
6982
  }
6371
6983
  },
6984
+ "GET /api/scan/issues": async (c) => {
6985
+ try {
6986
+ const todos = scanForTodos(TARGET_ROOT);
6987
+ const categorized = categorizeScannedIssues(todos, workflowDefinition);
6988
+ return c.json({ ok: true, issues: categorized, total: categorized.length });
6989
+ } catch (error) {
6990
+ logger.error({ err: error }, "Failed to scan for TODOs");
6991
+ return c.json({ ok: false, error: "Failed to scan for issues." }, 500);
6992
+ }
6993
+ },
6994
+ "POST /api/boot/skip-scan": async (c) => {
6995
+ broadcastToWebSocketClients({ type: "boot:scan:skipped" });
6996
+ return c.json({ ok: true, message: "Scan skipped." });
6997
+ },
6998
+ "GET /api/scan/github-issues": async (c) => {
6999
+ try {
7000
+ const issues = await fetchGitHubIssues(TARGET_ROOT);
7001
+ const categorized = categorizeScannedIssues(issues, workflowDefinition);
7002
+ return c.json({ ok: true, issues: categorized, total: categorized.length });
7003
+ } catch (error) {
7004
+ logger.error({ err: error }, "Failed to fetch GitHub issues");
7005
+ return c.json({ ok: false, error: "Failed to fetch GitHub issues." }, 500);
7006
+ }
7007
+ },
6372
7008
  "GET /api/catalog/agents": async (c) => {
6373
7009
  const domainsParam = c.req.query("domains");
6374
7010
  const domains = typeof domainsParam === "string" ? domainsParam.split(",").map((d) => d.trim()).filter(Boolean) : [];
@@ -6419,7 +7055,7 @@ async function startApiServer(state, port, workflowDefinition) {
6419
7055
  logger.info(`OpenAPI docs available at http://localhost:${port}/docs`);
6420
7056
  }
6421
7057
 
6422
- // src/runtime/store.ts
7058
+ // src/agent/store.ts
6423
7059
  var loadedS3dbModule = null;
6424
7060
  var stateDb = null;
6425
7061
  var runtimeStateResource = null;
@@ -6495,7 +7131,7 @@ async function initStateStore() {
6495
7131
  debugBoot("initStateStore:start");
6496
7132
  const { S3db, FileSystemClient, StateMachinePlugin } = await loadS3dbModule();
6497
7133
  debugBoot("initStateStore:module-loaded");
6498
- mkdirSync3(S3DB_DATABASE_PATH, { recursive: true });
7134
+ mkdirSync4(S3DB_DATABASE_PATH, { recursive: true });
6499
7135
  stateDb = new S3db({
6500
7136
  client: new FileSystemClient({
6501
7137
  basePath: S3DB_DATABASE_PATH,
@@ -6589,7 +7225,11 @@ function isStateNotFoundError(error) {
6589
7225
  return false;
6590
7226
  }
6591
7227
  async function loadPersistedState() {
6592
- if (!runtimeStateResource) return null;
7228
+ if (!runtimeStateResource) {
7229
+ logger.debug("[Store] No runtime state resource available, skipping load");
7230
+ return null;
7231
+ }
7232
+ logger.debug("[Store] Loading persisted state from s3db");
6593
7233
  try {
6594
7234
  const record2 = await runtimeStateResource.get(S3DB_RUNTIME_RECORD_ID);
6595
7235
  if (record2?.state && typeof record2.state === "object") {
@@ -6624,7 +7264,7 @@ async function recoverStateFromIssueResource() {
6624
7264
  config: {},
6625
7265
  issues,
6626
7266
  events: [],
6627
- metrics: computeMetrics(issues),
7267
+ metrics: getMetrics(issues),
6628
7268
  notes: ["State recovered from individual issue records after corruption."]
6629
7269
  };
6630
7270
  } catch (error) {
@@ -6634,20 +7274,30 @@ async function recoverStateFromIssueResource() {
6634
7274
  }
6635
7275
  async function persistState(state) {
6636
7276
  state.metrics = {
6637
- ...computeMetrics(state.issues),
7277
+ ...getMetrics(state.issues),
6638
7278
  activeWorkers: state.metrics.activeWorkers
6639
7279
  };
6640
7280
  if (!runtimeStateResource) return;
6641
- await runtimeStateResource.replace(S3DB_RUNTIME_RECORD_ID, {
6642
- id: S3DB_RUNTIME_RECORD_ID,
6643
- schemaVersion: S3DB_RUNTIME_SCHEMA_VERSION,
6644
- trackerKind: "filesystem",
6645
- runtimeTag: "local-only",
6646
- updatedAt: now(),
6647
- state
6648
- });
6649
- if (issueStateResource) {
7281
+ const dirty = hasDirtyState();
7282
+ const dirtyIssueCount = getDirtyIssueIds().size;
7283
+ const dirtyEventCount = getDirtyEventIds().size;
7284
+ if (dirty || dirtyIssueCount > 0 || dirtyEventCount > 0) {
7285
+ logger.debug({ dirty, dirtyIssues: dirtyIssueCount, dirtyEvents: dirtyEventCount }, "[Store] Persisting state");
7286
+ }
7287
+ if (dirty) {
7288
+ await runtimeStateResource.replace(S3DB_RUNTIME_RECORD_ID, {
7289
+ id: S3DB_RUNTIME_RECORD_ID,
7290
+ schemaVersion: S3DB_RUNTIME_SCHEMA_VERSION,
7291
+ trackerKind: "filesystem",
7292
+ runtimeTag: "local-only",
7293
+ updatedAt: now(),
7294
+ state
7295
+ });
7296
+ }
7297
+ const dirtyIssues = getDirtyIssueIds();
7298
+ if (issueStateResource && dirtyIssues.size > 0) {
6650
7299
  for (const issue of state.issues) {
7300
+ if (!dirtyIssues.has(issue.id)) continue;
6651
7301
  const clean = {
6652
7302
  ...issue,
6653
7303
  nextRetryAt: issue.nextRetryAt || void 0,
@@ -6662,11 +7312,15 @@ async function persistState(state) {
6662
7312
  logger.warn(`Failed to persist issue ${issue.id}: ${String(error)}`);
6663
7313
  }
6664
7314
  }
7315
+ clearDirtyIssueIds();
6665
7316
  }
6666
- if (eventStateResource) {
7317
+ const dirtyEvents = getDirtyEventIds();
7318
+ if (eventStateResource && dirtyEvents.size > 0) {
6667
7319
  for (const event of state.events) {
7320
+ if (!dirtyEvents.has(event.id)) continue;
6668
7321
  await eventStateResource.replace(event.id, event);
6669
7322
  }
7323
+ clearDirtyEventIds();
6670
7324
  }
6671
7325
  broadcastToWebSocketClients({
6672
7326
  type: "state:update",
@@ -6677,6 +7331,11 @@ async function persistState(state) {
6677
7331
  updatedAt: state.updatedAt
6678
7332
  });
6679
7333
  }
7334
+ async function persistStateFull(state) {
7335
+ markAllIssuesDirty(state.issues.map((i) => i.id));
7336
+ markAllEventsDirty(state.events.map((e) => e.id));
7337
+ await persistState(state);
7338
+ }
6680
7339
  async function loadPersistedSettings() {
6681
7340
  if (!settingStateResource?.list) return [];
6682
7341
  try {
@@ -6696,6 +7355,7 @@ async function replacePersistedSetting(setting) {
6696
7355
  await settingStateResource.replace(setting.id, setting);
6697
7356
  }
6698
7357
  async function closeStateStore() {
7358
+ logger.info("[Store] Closing state store and plugins");
6699
7359
  clearApiRuntimeContext();
6700
7360
  if (activeEcPlugin?.stop) {
6701
7361
  try {
@@ -6750,120 +7410,7 @@ async function closeStateStore() {
6750
7410
  }
6751
7411
  }
6752
7412
 
6753
- // src/runtime/workflow.ts
6754
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readdirSync as readdirSync5, readFileSync as readFileSync9, statSync as statSync3, writeFileSync as writeFileSync7 } from "fs";
6755
- import { extname } from "path";
6756
- import { argv as argv2, exit } from "process";
6757
- function bootstrapSource() {
6758
- if (existsSync10(SOURCE_MARKER)) return;
6759
- logger.info("Creating local source snapshot for Fifony (local-only runtime)...");
6760
- const skipDirs = /* @__PURE__ */ new Set([
6761
- ".git",
6762
- ".fifony",
6763
- "node_modules",
6764
- ".venv",
6765
- "data",
6766
- "dist",
6767
- "build",
6768
- ".turbo",
6769
- ".next",
6770
- ".nuxt",
6771
- ".tanstack",
6772
- "coverage",
6773
- "artifacts",
6774
- "captures",
6775
- "tmp",
6776
- "temp"
6777
- ]);
6778
- const shouldSkip = (relativePath) => {
6779
- const parts = relativePath.split("/");
6780
- if (parts.some((segment) => skipDirs.has(segment))) return true;
6781
- const base = relativePath.split("/").at(-1) ?? "";
6782
- if (base.startsWith("map_scan_") && extname(base) === ".json") return true;
6783
- if (extname(base) === ".xlsx") return true;
6784
- return false;
6785
- };
6786
- const copyRecursive = (source, target, rel = "") => {
6787
- mkdirSync4(target, { recursive: true });
6788
- const items = readdirSync5(source, { withFileTypes: true });
6789
- for (const item of items) {
6790
- const nextRel = rel ? `${rel}/${item.name}` : item.name;
6791
- if (shouldSkip(nextRel)) continue;
6792
- const sourcePath = `${source}/${item.name}`;
6793
- const targetPath = `${target}/${item.name}`;
6794
- const itemStat = statSync3(sourcePath);
6795
- if (item.isDirectory()) {
6796
- copyRecursive(sourcePath, targetPath, nextRel);
6797
- continue;
6798
- }
6799
- if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
6800
- if (itemStat.isFile() || itemStat.isFIFO()) {
6801
- try {
6802
- const file = readFileSync9(sourcePath);
6803
- writeFileSync7(targetPath, file);
6804
- } catch (error) {
6805
- if (error.code === "ENOENT") {
6806
- logger.debug(`Skipped missing source file: ${sourcePath}`);
6807
- } else {
6808
- throw error;
6809
- }
6810
- }
6811
- }
6812
- }
6813
- };
6814
- mkdirSync4(SOURCE_ROOT, { recursive: true });
6815
- copyRecursive(TARGET_ROOT, SOURCE_ROOT);
6816
- writeFileSync7(SOURCE_MARKER, `${now()}
6817
- `, "utf8");
6818
- }
6819
- function loadWorkflowDefinition() {
6820
- const defaultPrompt = PROMPT_TEMPLATES["workflow-default"];
6821
- return {
6822
- workflowPath: "",
6823
- rendered: "",
6824
- config: {},
6825
- promptTemplate: defaultPrompt,
6826
- agentProvider: "codex",
6827
- agentProfile: "",
6828
- agentProfilePath: "",
6829
- agentProfileInstructions: "",
6830
- agentProviders: [],
6831
- afterCreateHook: "",
6832
- beforeRunHook: "",
6833
- afterRunHook: "",
6834
- beforeRemoveHook: ""
6835
- };
6836
- }
6837
- function parsePort(args) {
6838
- for (let i = 0; i < args.length; i += 1) {
6839
- const arg = args[i];
6840
- if (arg === "--help" || arg === "-h") {
6841
- console.log(
6842
- `Usage: ${argv2[1]} [options]
6843
- Options:
6844
- --port <n> Start local dashboard (default: no UI and single batch run)
6845
- --workspace <path> Target workspace root (default: current directory)
6846
- --persistence <path> Persistence root (default: current directory)
6847
- --concurrency <n> Maximum number of parallel issue runners
6848
- --attempts <n> Maximum attempts per issue
6849
- --poll <ms> Polling interval for the scheduler
6850
- --once Run one local batch and exit
6851
- --help Show this message`
6852
- );
6853
- exit(0);
6854
- }
6855
- if (arg === "--port") {
6856
- const value = args[i + 1];
6857
- if (!value || !/^\d+$/.test(value)) {
6858
- fail(`Invalid value for --port: ${value ?? "<empty>"}`);
6859
- }
6860
- return parseIntArg(value, 4040);
6861
- }
6862
- }
6863
- return void 0;
6864
- }
6865
-
6866
- // src/runtime/dev-server.ts
7413
+ // src/agent/dev-server.ts
6867
7414
  import { resolve as resolve3 } from "path";
6868
7415
  var VITE_CONFIG_PATH = resolve3(PACKAGE_ROOT, "app/vite.config.js");
6869
7416
  async function startDevFrontend(apiPort, devPort) {
@@ -6901,7 +7448,7 @@ async function startDevFrontend(apiPort, devPort) {
6901
7448
  }
6902
7449
  }
6903
7450
 
6904
- // src/runtime/run-local.ts
7451
+ // src/agent/run-local.ts
6905
7452
  function usage() {
6906
7453
  console.log(
6907
7454
  `Usage: ${argv3[1]} [options]
@@ -6915,6 +7462,10 @@ Options:
6915
7462
  --timeout <ms> Agent command timeout in ms (default: 1800000)
6916
7463
  --dev Start Vite dev server alongside API (HMR on port+1)
6917
7464
  --once Process once and exit
7465
+ --skip-source Skip source snapshot copy
7466
+ --skip-scan Skip project analysis
7467
+ --skip-recovery Skip orphaned agent recovery
7468
+ --fast-boot Equivalent to --skip-source --skip-scan --skip-recovery
6918
7469
  `
6919
7470
  );
6920
7471
  }
@@ -6930,6 +7481,8 @@ async function main() {
6930
7481
  }
6931
7482
  mkdirSync5(STATE_ROOT, { recursive: true });
6932
7483
  initLogger(STATE_ROOT);
7484
+ logger.info("[Boot] Fifony runtime starting");
7485
+ logger.info({ stateRoot: STATE_ROOT, cwd: process.cwd() }, "[Boot] State root initialized");
6933
7486
  const detectedProviders = detectAvailableProviders();
6934
7487
  for (const p of detectedProviders) {
6935
7488
  logger.info(`Provider ${p.name}: ${p.available ? `available at ${p.path}` : "not found"}`);
@@ -6937,8 +7490,13 @@ async function main() {
6937
7490
  const interfaceMode = (env10.FIFONY_INTERFACE ?? "cli").trim().toLowerCase();
6938
7491
  const runOnce = args.includes("--once");
6939
7492
  const devMode = args.includes("--dev") || env10.NODE_ENV === "development";
7493
+ const fastBoot = args.includes("--fast-boot");
7494
+ const skipSource = fastBoot || args.includes("--skip-source");
7495
+ if (skipSource) setSkipSource(true);
6940
7496
  debugBoot("main:state-root-ready");
7497
+ logger.debug("[Boot] Loading workflow definition");
6941
7498
  const workflowDefinition = loadWorkflowDefinition();
7499
+ logger.info({ workflowPath: workflowDefinition.workflowPath }, "[Boot] Workflow definition loaded");
6942
7500
  debugBoot("main:workflow-loaded");
6943
7501
  const port = parsePort(args);
6944
7502
  let config = applyWorkflowConfig(deriveConfig(args), workflowDefinition, port);
@@ -6953,14 +7511,44 @@ async function main() {
6953
7511
  }
6954
7512
  }
6955
7513
  const dashboardPort = port ?? (config.dashboardPort ? Number.parseInt(config.dashboardPort, 10) : void 0);
6956
- bootstrapSource();
6957
- debugBoot("main:source-bootstrapped");
7514
+ const skipRecovery = args.includes("--skip-recovery") || args.includes("--fast-boot");
7515
+ debugBoot("main:phase-b-start");
7516
+ logger.debug("[Boot] Initializing state store (s3db)");
6958
7517
  await initStateStore();
7518
+ logger.info("[Boot] State store initialized");
6959
7519
  debugBoot("main:store-initialized");
6960
- await persistDetectedProvidersSetting(detectedProviders);
6961
- await recoverPlanningSession();
6962
- const previous = await loadPersistedState();
6963
- const persistedSettings = await loadRuntimeSettings();
7520
+ const earlyState = {
7521
+ startedAt: now(),
7522
+ updatedAt: now(),
7523
+ trackerKind: "filesystem",
7524
+ sourceRepoUrl: "",
7525
+ sourceRef: "workspace",
7526
+ workflowPath: "",
7527
+ config,
7528
+ issues: [],
7529
+ events: [],
7530
+ metrics: { total: 0, queued: 0, inProgress: 0, blocked: 0, done: 0, cancelled: 0, activeWorkers: 0 },
7531
+ notes: [],
7532
+ booting: true
7533
+ };
7534
+ let apiState = earlyState;
7535
+ if (dashboardPort) {
7536
+ await startApiServer(apiState, dashboardPort, workflowDefinition);
7537
+ debugBoot("main:api-server-early-start");
7538
+ if (devMode) {
7539
+ const devPort = dashboardPort + 1;
7540
+ await startDevFrontend(dashboardPort, devPort);
7541
+ }
7542
+ }
7543
+ debugBoot("main:phase-c-start");
7544
+ logger.debug("[Boot] Loading persisted state, settings, and recovering sessions");
7545
+ const [previous, persistedSettings] = await Promise.all([
7546
+ loadPersistedState(),
7547
+ loadRuntimeSettings(),
7548
+ persistDetectedProvidersSetting(detectedProviders),
7549
+ recoverPlanningSession()
7550
+ ]);
7551
+ logger.info({ hadPreviousState: previous !== null, issueCount: previous?.issues?.length ?? 0, settingsCount: persistedSettings.length }, "[Boot] State loaded from persistence");
6964
7552
  debugBoot("main:state-loaded");
6965
7553
  config = applyPersistedSettings(config, persistedSettings);
6966
7554
  await syncRuntimeConfigSettings(config, persistedSettings);
@@ -6969,6 +7557,7 @@ async function main() {
6969
7557
  state.config.dashboardPort = dashboardPort ? String(dashboardPort) : void 0;
6970
7558
  state.workflowPath = WORKFLOW_RENDERED;
6971
7559
  state.updatedAt = now();
7560
+ state.booting = false;
6972
7561
  if (state.config.agentCommand) {
6973
7562
  state.notes.push(`Using agent command: ${state.config.agentCommand}`);
6974
7563
  }
@@ -6998,25 +7587,32 @@ async function main() {
6998
7587
  logger.info("Background workspace cleanup complete.");
6999
7588
  });
7000
7589
  }
7001
- for (const issue of state.issues) {
7002
- if (issue.state === "Running" || issue.state === "Interrupted" || issue.state === "Queued") {
7003
- const { alive, pid } = isAgentStillRunning(issue);
7004
- if (alive && pid) {
7005
- logger.info(`Agent for ${issue.identifier} still alive (PID ${pid.pid}), keeping state as Running.`);
7006
- issue.state = "Running";
7007
- addEvent(state, issue.id, "info", `Orphaned agent detected (PID ${pid.pid}), still alive \u2014 tracking resumed.`);
7008
- } else {
7009
- if (issue.workspacePath) cleanStalePidFile(issue.workspacePath);
7010
- if (issue.state === "Running") {
7011
- issue.state = "Interrupted";
7012
- issue.history.push(`[${now()}] Agent process not found on boot \u2014 marked Interrupted.`);
7013
- addEvent(state, issue.id, "info", `Agent for ${issue.identifier} not found, marked Interrupted.`);
7590
+ if (!skipRecovery) {
7591
+ logger.debug({ issueCount: state.issues.filter((i) => i.state === "Running" || i.state === "Interrupted" || i.state === "Queued").length }, "[Boot] Checking for orphaned agent processes");
7592
+ for (const issue of state.issues) {
7593
+ if (issue.state === "Running" || issue.state === "Interrupted" || issue.state === "Queued") {
7594
+ const { alive, pid } = isAgentStillRunning(issue);
7595
+ if (alive && pid) {
7596
+ logger.info(`Agent for ${issue.identifier} still alive (PID ${pid.pid}), keeping state as Running.`);
7597
+ issue.state = "Running";
7598
+ addEvent(state, issue.id, "info", `Orphaned agent detected (PID ${pid.pid}), still alive \u2014 tracking resumed.`);
7599
+ } else {
7600
+ if (issue.workspacePath) cleanStalePidFile(issue.workspacePath);
7601
+ if (issue.state === "Running") {
7602
+ issue.state = "Interrupted";
7603
+ issue.history.push(`[${now()}] Agent process not found on boot \u2014 marked Interrupted.`);
7604
+ addEvent(state, issue.id, "info", `Agent for ${issue.identifier} not found, marked Interrupted.`);
7605
+ }
7014
7606
  }
7015
7607
  }
7016
7608
  }
7017
7609
  }
7018
7610
  state.metrics = computeMetrics(state.issues);
7019
- await persistState(state);
7611
+ await persistStateFull(state);
7612
+ if (dashboardPort) {
7613
+ Object.assign(apiState, state);
7614
+ debugBoot("main:api-state-swapped");
7615
+ }
7020
7616
  const running = /* @__PURE__ */ new Set();
7021
7617
  installGracefulShutdown(state, running);
7022
7618
  logger.info(`Rendered local workflow: ${WORKFLOW_RENDERED}`);
@@ -7027,16 +7623,10 @@ async function main() {
7027
7623
  logger.info(`Max turns: ${state.config.maxTurns}`);
7028
7624
  logger.info(`Agent provider: ${state.config.agentProvider}`);
7029
7625
  logger.info(`Interface mode: ${interfaceMode}`);
7030
- if (dashboardPort) {
7031
- await startApiServer(state, dashboardPort, workflowDefinition);
7032
- if (devMode) {
7033
- const devPort = dashboardPort + 1;
7034
- await startDevFrontend(dashboardPort, devPort);
7035
- }
7036
- }
7037
7626
  try {
7038
7627
  addEvent(state, void 0, "info", `Runtime started in local-only mode (filesystem tracker).`);
7039
7628
  const runForever = !runOnce && (Boolean(dashboardPort) || interfaceMode === "mcp");
7629
+ logger.info({ runForever, runOnce, dashboardPort, interfaceMode }, "[Boot] Entering scheduler loop");
7040
7630
  await scheduler(state, running, runForever, workflowDefinition);
7041
7631
  } catch (error) {
7042
7632
  console.error("FATAL STACK TRACE:", error);
@@ -7046,7 +7636,7 @@ async function main() {
7046
7636
  } finally {
7047
7637
  state.updatedAt = now();
7048
7638
  state.metrics = computeMetrics(state.issues);
7049
- await persistState(state);
7639
+ await persistStateFull(state);
7050
7640
  await closeStateStore();
7051
7641
  }
7052
7642
  }