@mestreyoda/fabrica 0.2.3 → 0.2.7

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.
@@ -73,8 +73,7 @@ Do **not** treat the task envelope (`Repo:`, `Project:`, `Channel:`, branch hint
73
73
 
74
74
  - Read the PR diff carefully
75
75
  - Check the code against the review checklist
76
- - Call `review_submit` with your review findings so the artifact is written to the PR itself
77
- - Then call `work_finish`
76
+ - Submit your review using **one of the two methods below** (prefer `review_submit` if available)
78
77
 
79
78
  ## Conventions
80
79
 
@@ -92,22 +91,50 @@ If you discover unrelated bugs or needed improvements, call `task_create`:
92
91
 
93
92
  ## Completing Your Task
94
93
 
95
- When you are done, submit the review artifact first, then **call `work_finish` yourself** do not just announce in text.
94
+ When you are done, submit your review using **Method A** if the tools are available, or **Method B** otherwise.
96
95
 
97
- - **Approve review artifact:** `review_submit({ channelId: "<project slug from 'Project:' field in task message>", issueId: <issue number>, result: "approve", body: "<what you checked>" })`
98
- - **Reject review artifact:** `review_submit({ channelId: "<project slug from 'Project:' field in task message>", issueId: <issue number>, result: "reject", body: "<specific issues>" })`
99
- - Capture the returned `artifactId` and `artifactType` from `review_submit`.
96
+ ### Method A Fabrica tools (preferred)
100
97
 
101
- **Never call `task_comment` for review findings.** The orchestrator may mirror your result to the issue separately, but your authoritative feedback must live on the PR via `review_submit`.
102
-
103
- - **Approve:** `work_finish({ role: "reviewer", result: "approve", channelId: "<project slug from 'Project:' field in task message>", summary: "<what you checked>", reviewArtifactId: <artifactId>, reviewArtifactType: "<artifactType>" })`
104
- - **Reject:** `work_finish({ role: "reviewer", result: "reject", channelId: "<project slug from 'Project:' field in task message>", summary: "<specific issues>", reviewArtifactId: <artifactId>, reviewArtifactType: "<artifactType>" })`
105
- - **Blocked:** `work_finish({ role: "reviewer", result: "blocked", channelId: "<project slug from 'Project:' field in task message>", summary: "<what you need>" })`
98
+ 1. Call `review_submit` to write the review artifact to the PR:
99
+ - **Approve:** `review_submit({ channelId: "<project slug>", issueId: <issue number>, result: "approve", body: "<what you checked>" })`
100
+ - **Reject:** `review_submit({ channelId: "<project slug>", issueId: <issue number>, result: "reject", body: "<specific issues>" })`
101
+ - Capture the returned `artifactId` and `artifactType`.
102
+ 2. Then call `work_finish`:
103
+ - **Approve:** `work_finish({ role: "reviewer", result: "approve", channelId: "<project slug>", summary: "<what you checked>", reviewArtifactId: <artifactId>, reviewArtifactType: "<artifactType>" })`
104
+ - **Reject:** `work_finish({ role: "reviewer", result: "reject", channelId: "<project slug>", summary: "<specific issues>", reviewArtifactId: <artifactId>, reviewArtifactType: "<artifactType>" })`
105
+ - **Blocked:** `work_finish({ role: "reviewer", result: "blocked", channelId: "<project slug>", summary: "<what you need>" })`
106
106
 
107
107
  > **IMPORTANT:** The `channelId` parameter accepts the project slug (e.g., "gestao-notas").
108
108
  > Extract it from the "Project: <name>" line in your task message. Do NOT use the numeric
109
109
  > channel ID — use the project slug to avoid resolution errors when channels are shared.
110
110
 
111
+ ### Method B — GitHub CLI fallback (use only if `review_submit` / `work_finish` are unavailable)
112
+
113
+ Extract from your task message:
114
+ - `OWNER/REPO` from the `Repo:` line
115
+ - `PR_NUMBER` from the PR URL in the diff header or the `Branch:` line
116
+ - `ISSUE_NUMBER` from the `Issue:` field
117
+
118
+ **Approve:**
119
+ ```bash
120
+ gh pr review PR_NUMBER --repo OWNER/REPO --approve -b "$(cat <<'EOF'
121
+ <your full review body here>
122
+ EOF
123
+ )"
124
+ ```
125
+
126
+ **Reject (request changes):**
127
+ ```bash
128
+ gh pr review PR_NUMBER --repo OWNER/REPO --request-changes -b "$(cat <<'EOF'
129
+ <specific issues and how to fix them>
130
+ EOF
131
+ )"
132
+ ```
133
+
134
+ After submitting via `gh pr review`, the Fabrica heartbeat will detect the PR review state and advance the pipeline automatically. **Do NOT manually edit issue labels.**
135
+
136
+ **Never call `task_comment` for review findings.** Your review must be posted on the PR itself.
137
+
111
138
  ## Tools You Should NOT Use
112
139
 
113
140
  These are orchestrator-only tools. Do not call them:
package/dist/index.js CHANGED
@@ -110821,7 +110821,7 @@ var init_registry = __esm({
110821
110821
  });
110822
110822
 
110823
110823
  // lib/config/merge.ts
110824
- function mergeConfig(base, overlay) {
110824
+ function mergeConfig(base, overlay, traceOpts) {
110825
110825
  const merged = {};
110826
110826
  if (base.roles || overlay.roles) {
110827
110827
  merged.roles = { ...base.roles };
@@ -110872,6 +110872,44 @@ function mergeConfig(base, overlay) {
110872
110872
  } : void 0
110873
110873
  };
110874
110874
  }
110875
+ if (traceOpts) {
110876
+ const { baseLabel, overlayLabel } = traceOpts;
110877
+ const trace2 = {};
110878
+ if (merged.workflow) {
110879
+ for (const key of ["initial", "reviewPolicy", "testPolicy", "roleExecution", "maxWorkersPerLevel"]) {
110880
+ if (merged.workflow[key] !== void 0) {
110881
+ const fromOverlay = overlay.workflow?.[key] !== void 0;
110882
+ trace2[`workflow.${key}`] = fromOverlay ? overlayLabel : baseLabel;
110883
+ }
110884
+ }
110885
+ }
110886
+ if (merged.timeouts) {
110887
+ for (const [key, value] of Object.entries(merged.timeouts)) {
110888
+ if (value !== void 0) {
110889
+ const fromOverlay = overlay.timeouts?.[key] !== void 0;
110890
+ trace2[`timeouts.${key}`] = fromOverlay ? overlayLabel : baseLabel;
110891
+ }
110892
+ }
110893
+ }
110894
+ if (merged.roles) {
110895
+ for (const [roleId, roleValue] of Object.entries(merged.roles)) {
110896
+ if (roleValue === false) {
110897
+ trace2[`roles.${roleId}`] = overlay.roles?.[roleId] === false ? overlayLabel : baseLabel;
110898
+ continue;
110899
+ }
110900
+ if (typeof roleValue === "object") {
110901
+ for (const key of ["defaultLevel", "levels", "completionResults"]) {
110902
+ if (roleValue[key] !== void 0) {
110903
+ const overlayRole = overlay.roles?.[roleId];
110904
+ const fromOverlay = typeof overlayRole === "object" && overlayRole?.[key] !== void 0;
110905
+ trace2[`roles.${roleId}.${key}`] = fromOverlay ? overlayLabel : baseLabel;
110906
+ }
110907
+ }
110908
+ }
110909
+ }
110910
+ }
110911
+ return Object.assign(merged, { _trace: trace2 });
110912
+ }
110875
110913
  return merged;
110876
110914
  }
110877
110915
  function mergeWorkflowStates(base, overlay) {
@@ -110917,42 +110955,10 @@ function mergeRoleOverride(base, overlay) {
110917
110955
  };
110918
110956
  }
110919
110957
  function mergeConfigWithTrace(base, overlay, baseLabel, overlayLabel) {
110920
- const merged = mergeConfig(base, overlay);
110921
- const trace2 = {};
110922
- if (merged.workflow) {
110923
- for (const key of ["initial", "reviewPolicy", "testPolicy", "roleExecution", "maxWorkersPerLevel"]) {
110924
- if (merged.workflow[key] !== void 0) {
110925
- const fromOverlay = overlay.workflow?.[key] !== void 0;
110926
- trace2[`workflow.${key}`] = fromOverlay ? overlayLabel : baseLabel;
110927
- }
110928
- }
110929
- }
110930
- if (merged.timeouts) {
110931
- for (const [key, value] of Object.entries(merged.timeouts)) {
110932
- if (value !== void 0) {
110933
- const fromOverlay = overlay.timeouts?.[key] !== void 0;
110934
- trace2[`timeouts.${key}`] = fromOverlay ? overlayLabel : baseLabel;
110935
- }
110936
- }
110937
- }
110938
- if (merged.roles) {
110939
- for (const [roleId, roleValue] of Object.entries(merged.roles)) {
110940
- if (roleValue === false) {
110941
- trace2[`roles.${roleId}`] = overlay.roles?.[roleId] === false ? overlayLabel : baseLabel;
110942
- continue;
110943
- }
110944
- if (typeof roleValue === "object") {
110945
- for (const key of ["defaultLevel", "levels", "completionResults"]) {
110946
- if (roleValue[key] !== void 0) {
110947
- const overlayRole = overlay.roles?.[roleId];
110948
- const fromOverlay = typeof overlayRole === "object" && overlayRole?.[key] !== void 0;
110949
- trace2[`roles.${roleId}.${key}`] = fromOverlay ? overlayLabel : baseLabel;
110950
- }
110951
- }
110952
- }
110953
- }
110954
- }
110955
- return { merged, trace: trace2 };
110958
+ const result = mergeConfig(base, overlay, { baseLabel, overlayLabel });
110959
+ const trace2 = result._trace ?? {};
110960
+ delete result._trace;
110961
+ return { merged: result, trace: trace2 };
110956
110962
  }
110957
110963
  var init_merge = __esm({
110958
110964
  "lib/config/merge.ts"() {
@@ -111324,8 +111330,8 @@ import fsSync from "node:fs";
111324
111330
  import path5 from "node:path";
111325
111331
  import { fileURLToPath as fileURLToPath3 } from "node:url";
111326
111332
  function getCurrentVersion() {
111327
- if ("0.2.3") {
111328
- return "0.2.3";
111333
+ if ("0.2.7") {
111334
+ return "0.2.7";
111329
111335
  }
111330
111336
  try {
111331
111337
  const pkgPath = path5.join(THIS_DIR, "..", "..", "package.json");
@@ -117132,6 +117138,8 @@ var GitHubRateLimitError = class extends Error {
117132
117138
  this.name = "GitHubRateLimitError";
117133
117139
  }
117134
117140
  };
117141
+ var MAX_RATE_LIMIT_RETRIES = 2;
117142
+ var JITTER_MAX_MS = 5e3;
117135
117143
  var MAX_ENTRIES = 50;
117136
117144
  var policyCache = /* @__PURE__ */ new Map();
117137
117145
  var accessOrder = [];
@@ -117167,9 +117175,27 @@ function getProviderPolicy(providerKey) {
117167
117175
  accessOrder.push(providerKey);
117168
117176
  return policy;
117169
117177
  }
117170
- function withResilience(fn, providerKey) {
117178
+ async function withResilience(fn, providerKey, opts) {
117171
117179
  const policy = providerKey ? getProviderPolicy(providerKey) : getProviderPolicy("__global__");
117172
- return policy.execute(() => fn());
117180
+ const jitterMax = opts?.jitterMaxMs ?? JITTER_MAX_MS;
117181
+ const withRateLimitRetry = async () => {
117182
+ let lastError;
117183
+ for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {
117184
+ try {
117185
+ return await fn();
117186
+ } catch (err) {
117187
+ if (err instanceof GitHubRateLimitError && attempt < MAX_RATE_LIMIT_RETRIES) {
117188
+ lastError = err;
117189
+ const jitter = jitterMax > 0 ? Math.floor(Math.random() * jitterMax) : 0;
117190
+ await new Promise((resolve3) => setTimeout(resolve3, err.retryAfterMs + jitter));
117191
+ continue;
117192
+ }
117193
+ throw err;
117194
+ }
117195
+ }
117196
+ throw lastError;
117197
+ };
117198
+ return policy.execute(withRateLimitRetry);
117173
117199
  }
117174
117200
  var providerPolicy = getProviderPolicy("__legacy_global__");
117175
117201
 
@@ -118078,7 +118104,9 @@ var GitHubProvider = class {
118078
118104
  if (result.code != null && result.code !== 0) {
118079
118105
  const errText = result.stderr?.trim() ?? "";
118080
118106
  if (errText.includes("rate limit") || errText.includes("429")) {
118081
- throw new GitHubRateLimitError(6e4);
118107
+ const retryMatch = errText.match(/retry after (\d+)/i);
118108
+ const retryMs = retryMatch ? parseInt(retryMatch[1], 10) * 1e3 : 6e4;
118109
+ throw new GitHubRateLimitError(retryMs);
118082
118110
  }
118083
118111
  throw new Error(errText || `gh api ${method} ${endpoint} failed with exit code ${result.code}`);
118084
118112
  }
@@ -122967,7 +122995,8 @@ ${roleInstructions}`;
122967
122995
  return effortPrefix ?? roleInstructions ?? "";
122968
122996
  }
122969
122997
  function sendToAgent(sessionKey, taskMessage, opts) {
122970
- const idempotencyKey = `fabrica-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${opts.slotIndex ?? 0}-${opts.fromLabel ?? "unknown"}-${sessionKey}`;
122998
+ const epoch = opts.dispatchEpoch ?? (/* @__PURE__ */ new Date()).toISOString();
122999
+ const idempotencyKey = `fabrica-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${opts.slotIndex ?? 0}-${opts.fromLabel ?? "unknown"}-${sessionKey}-${epoch}`;
122971
123000
  if (opts.runtime?.subagent?.run) {
122972
123001
  opts.runtime.subagent.run({
122973
123002
  sessionKey,
@@ -123388,6 +123417,7 @@ async function dispatchTask(opts) {
123388
123417
  error: err.message ?? String(err)
123389
123418
  }).catch((auditErr) => console.error("[fabrica] silent-catch:", auditErr.message));
123390
123419
  });
123420
+ const dispatchEpoch = (/* @__PURE__ */ new Date()).toISOString();
123391
123421
  sendToAgent(sessionKey, taskMessage, {
123392
123422
  agentId,
123393
123423
  projectName: project.name,
@@ -123405,7 +123435,8 @@ async function dispatchTask(opts) {
123405
123435
  roleInstructions.trim() || void 0
123406
123436
  ) || void 0,
123407
123437
  runCommand: rc,
123408
- runtime
123438
+ runtime,
123439
+ dispatchEpoch
123409
123440
  });
123410
123441
  await recordIssueLifecycle({
123411
123442
  workspaceDir,
@@ -130457,7 +130488,10 @@ async function cleanupExpired(workspaceDir, ttlMs = DEFAULT_TTL_MS2) {
130457
130488
  const cutoff = Date.now() - ttlMs;
130458
130489
  const kept = entries.filter((e2) => e2.ts >= cutoff);
130459
130490
  if (kept.length < entries.length) {
130460
- await fs28.writeFile(filePath, kept.map((e2) => JSON.stringify(e2)).join("\n") + (kept.length > 0 ? "\n" : ""), "utf-8");
130491
+ const content = kept.map((e2) => JSON.stringify(e2)).join("\n") + (kept.length > 0 ? "\n" : "");
130492
+ const tmpPath = filePath + ".tmp";
130493
+ await fs28.writeFile(tmpPath, content, "utf-8");
130494
+ await fs28.rename(tmpPath, filePath);
130461
130495
  }
130462
130496
  }
130463
130497
 
@@ -130757,7 +130791,7 @@ async function reviewPass(opts) {
130757
130791
  const issues = await provider.listIssuesByLabel(state.label);
130758
130792
  for (const issue2 of issues) {
130759
130793
  const routing = detectStepRouting(issue2.labels, "review");
130760
- if (routing !== "human") continue;
130794
+ if (routing !== "human" && routing !== "agent") continue;
130761
130795
  const isManaged = await provider.issueHasReaction(issue2.iid, "eyes");
130762
130796
  if (!isManaged) continue;
130763
130797
  const status = await provider.getPrStatus(issue2.iid);
@@ -132206,23 +132240,46 @@ async function checkProjectActive(workspaceDir, slug) {
132206
132240
  );
132207
132241
  }
132208
132242
 
132243
+ // lib/services/heartbeat/wake-bridge.ts
132244
+ var _wakeCallback = null;
132245
+ function setPluginWakeHandler(cb) {
132246
+ _wakeCallback = cb;
132247
+ }
132248
+ async function wakeHeartbeat(reason) {
132249
+ await _wakeCallback?.(reason);
132250
+ }
132251
+
132209
132252
  // lib/services/heartbeat/index.ts
132210
132253
  function registerHeartbeatService(api, pluginCtx) {
132211
132254
  let sharedIntervalId = null;
132212
- let tickCount = 0;
132255
+ let _pendingWake = false;
132213
132256
  api.registerService({
132214
132257
  id: "fabrica-heartbeat",
132215
132258
  start: async (svcCtx) => {
132216
132259
  const { intervalSeconds } = resolveHeartbeatConfig(pluginCtx.pluginConfig);
132217
132260
  const intervalMs = intervalSeconds * 1e3;
132218
- setTimeout(() => runHeartbeatTick(pluginCtx, svcCtx.logger, "repair"), 2e3);
132261
+ setTimeout(() => runHeartbeatTick(pluginCtx, svcCtx.logger, "full"), 2e3);
132219
132262
  sharedIntervalId = setInterval(() => {
132220
- const mode = tickCount % 2 === 0 ? "repair" : "triage";
132221
- tickCount++;
132222
- runHeartbeatTick(pluginCtx, svcCtx.logger, mode);
132263
+ runHeartbeatTick(pluginCtx, svcCtx.logger, "full").finally(() => {
132264
+ if (_pendingWake) {
132265
+ _pendingWake = false;
132266
+ svcCtx.logger.info("heartbeat_wake: running deferred full tick");
132267
+ runHeartbeatTick(pluginCtx, svcCtx.logger, "full");
132268
+ }
132269
+ });
132223
132270
  }, intervalMs);
132271
+ setPluginWakeHandler(async (reason) => {
132272
+ if (_anyTickRunning) {
132273
+ _pendingWake = true;
132274
+ svcCtx.logger.info(`heartbeat_wake: deferred (tick-in-progress), reason=${reason}`);
132275
+ return;
132276
+ }
132277
+ svcCtx.logger.info(`heartbeat_wake: running full tick, reason=${reason}`);
132278
+ await runHeartbeatTick(pluginCtx, svcCtx.logger, "full");
132279
+ });
132224
132280
  },
132225
132281
  stop: async (svcCtx) => {
132282
+ setPluginWakeHandler(null);
132226
132283
  if (sharedIntervalId) {
132227
132284
  clearInterval(sharedIntervalId);
132228
132285
  sharedIntervalId = null;
@@ -139108,7 +139165,7 @@ function registerTelegramBootstrapHook(api, ctx) {
139108
139165
  ctx.logger.info(`[telegram-bootstrap] stale received session (expired) \u2014 restarting pipeline for conversation ${conversationId}`);
139109
139166
  }
139110
139167
  }
139111
- if (!parsed.projectName && ctx.runtime?.subagent?.run) {
139168
+ if (!parsed.projectName && ctx.runtime?.subagent?.run != null) {
139112
139169
  await upsertTelegramBootstrapSession(workspaceDir, {
139113
139170
  conversationId,
139114
139171
  rawIdea: parsed.rawIdea,
@@ -139484,6 +139541,8 @@ function registerReactiveDispatchHooks(api, ctx) {
139484
139541
  api.on("after_tool_call", async (event, _eventCtx) => {
139485
139542
  if (!COMPLETION_TOOLS.has(event.toolName)) return;
139486
139543
  ctx.runtime?.system.requestHeartbeatNow({ reason: "work_finish", coalesceMs: 2e3 });
139544
+ wakeHeartbeat("work_finish").catch(() => {
139545
+ });
139487
139546
  });
139488
139547
  api.on("agent_end", async (_event, eventCtx) => {
139489
139548
  const sessionKey = eventCtx.sessionKey;
@@ -139491,6 +139550,8 @@ function registerReactiveDispatchHooks(api, ctx) {
139491
139550
  const parsed = parseFabricaSessionKey(sessionKey);
139492
139551
  if (!parsed) return;
139493
139552
  ctx.runtime?.system.requestHeartbeatNow({ reason: "agent_end", coalesceMs: 2e3 });
139553
+ wakeHeartbeat("agent_end").catch(() => {
139554
+ });
139494
139555
  });
139495
139556
  api.on("subagent_spawned", async (event, _eventCtx) => {
139496
139557
  const sessionKey = event.childSessionKey;
@@ -139500,6 +139561,7 @@ function registerReactiveDispatchHooks(api, ctx) {
139500
139561
  }
139501
139562
 
139502
139563
  // lib/dispatch/subagent-lifecycle-hook.ts
139564
+ init_workflow();
139503
139565
  function registerSubagentLifecycleHook(api, ctx) {
139504
139566
  const workspaceDir = resolveWorkspaceDir(ctx.config);
139505
139567
  if (!workspaceDir) return;
@@ -139523,6 +139585,67 @@ function registerSubagentLifecycleHook(api, ctx) {
139523
139585
  ctx.logger.info(
139524
139586
  `subagent_ended: worker ${role} in "${projectName}" ended with outcome=${event.outcome ?? "unknown"} (session=${sessionKey})`
139525
139587
  );
139588
+ try {
139589
+ const projects = await readProjects(workspaceDir);
139590
+ const projectEntry = Object.entries(projects.projects).find(
139591
+ ([, p]) => p.name === projectName
139592
+ );
139593
+ if (!projectEntry) return;
139594
+ const [projectSlug, project] = projectEntry;
139595
+ const roleWorker = project.workers[role];
139596
+ if (!roleWorker) return;
139597
+ let foundLevel;
139598
+ let foundSlotIndex;
139599
+ let foundSlot;
139600
+ for (const [level, slots] of Object.entries(roleWorker.levels)) {
139601
+ for (let i2 = 0; i2 < slots.length; i2++) {
139602
+ if (slots[i2].sessionKey === sessionKey && slots[i2].active) {
139603
+ foundLevel = level;
139604
+ foundSlotIndex = i2;
139605
+ foundSlot = slots[i2];
139606
+ break;
139607
+ }
139608
+ }
139609
+ if (foundSlot) break;
139610
+ }
139611
+ if (!foundSlot || foundLevel == null || foundSlotIndex == null) return;
139612
+ const issueId = foundSlot.issueId;
139613
+ await deactivateWorker(workspaceDir, projectSlug, role, {
139614
+ level: foundLevel,
139615
+ slotIndex: foundSlotIndex
139616
+ });
139617
+ if (issueId) {
139618
+ try {
139619
+ const config2 = await loadConfig(workspaceDir, projectSlug);
139620
+ const activeLabel = getActiveLabel(config2.workflow, role);
139621
+ const revertLabel = getRevertLabel(config2.workflow, role);
139622
+ const { provider } = await createProvider({
139623
+ repo: project.repo,
139624
+ provider: project.provider,
139625
+ runCommand: ctx.runCommand
139626
+ });
139627
+ const issue2 = await provider.getIssue(Number(issueId));
139628
+ if (issue2.labels.includes(activeLabel)) {
139629
+ await provider.transitionLabel(Number(issueId), activeLabel, revertLabel);
139630
+ }
139631
+ } catch {
139632
+ }
139633
+ }
139634
+ await log(workspaceDir, "subagent_ended_slot_cleanup", {
139635
+ sessionKey,
139636
+ project: projectName,
139637
+ role,
139638
+ level: foundLevel,
139639
+ slotIndex: foundSlotIndex,
139640
+ issueId,
139641
+ outcome: event.outcome ?? "unknown"
139642
+ }).catch(() => {
139643
+ });
139644
+ wakeHeartbeat("subagent_ended").catch(() => {
139645
+ });
139646
+ } catch (err) {
139647
+ ctx.logger.warn(`subagent_ended_slot_cleanup failed: ${err.message}`);
139648
+ }
139526
139649
  });
139527
139650
  }
139528
139651