@mestreyoda/fabrica 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -111324,8 +111324,8 @@ import fsSync from "node:fs";
111324
111324
  import path5 from "node:path";
111325
111325
  import { fileURLToPath as fileURLToPath3 } from "node:url";
111326
111326
  function getCurrentVersion() {
111327
- if ("0.2.1") {
111328
- return "0.2.1";
111327
+ if ("0.2.3") {
111328
+ return "0.2.3";
111329
111329
  }
111330
111330
  try {
111331
111331
  const pkgPath = path5.join(THIS_DIR, "..", "..", "package.json");
@@ -124578,103 +124578,6 @@ function createProjectRegisterTool(ctx) {
124578
124578
  import { jsonResult as jsonResult15 } from "openclaw/plugin-sdk";
124579
124579
  init_audit();
124580
124580
 
124581
- // lib/services/heartbeat/diagnostic.ts
124582
- import { execFileSync } from "child_process";
124583
- async function diagnoseStall(input) {
124584
- const { owner, repo, issueId, dispatchAttemptCount } = input;
124585
- if ((dispatchAttemptCount ?? 0) >= 2) {
124586
- const hasArtifacts = await checkForArtifacts(owner, repo, issueId);
124587
- if (!hasArtifacts) {
124588
- return { action: "needs_human_review", reason: "stall", evidence: `${dispatchAttemptCount} attempts, zero artifacts` };
124589
- }
124590
- }
124591
- const pr = await findPrForIssue(owner, repo, issueId);
124592
- if (pr) {
124593
- const qaStatus = await checkPrQaStatus(owner, repo, pr.number);
124594
- if (qaStatus === "pass") {
124595
- return { action: "transition_to_review", reason: "stall", evidence: `PR #${pr.number} exists, QA passing`, prNumber: pr.number };
124596
- }
124597
- return { action: "redispatch_same_level", reason: "stall", evidence: `PR #${pr.number} exists, QA ${qaStatus}`, prNumber: pr.number };
124598
- }
124599
- const hasCommits = await checkForBranchCommits(owner, repo, issueId);
124600
- if (hasCommits) {
124601
- return { action: "nudge_open_pr", reason: "stall", evidence: "Branch has commits but no PR" };
124602
- }
124603
- const sessionAge = Date.now() - input.sessionUpdatedAt;
124604
- const isSessionDead = sessionAge > 30 * 6e4;
124605
- if (isSessionDead) {
124606
- return { action: "log_infra", reason: "infra", evidence: "Session dead, zero artifacts" };
124607
- }
124608
- return { action: "escalate_level", reason: "complexity", evidence: "Active session without commits" };
124609
- }
124610
- function ghSync(args) {
124611
- return execFileSync("gh", args, { encoding: "utf-8", timeout: 15e3 });
124612
- }
124613
- async function findPrForIssue(owner, repo, issueId) {
124614
- if (!owner || !repo) return null;
124615
- try {
124616
- const stdout = ghSync([
124617
- "pr",
124618
- "list",
124619
- "--repo",
124620
- `${owner}/${repo}`,
124621
- "--search",
124622
- `${issueId}`,
124623
- "--json",
124624
- "number",
124625
- "--limit",
124626
- "1"
124627
- ]);
124628
- const prs = JSON.parse(stdout);
124629
- return prs.length > 0 ? prs[0] : null;
124630
- } catch {
124631
- return null;
124632
- }
124633
- }
124634
- async function checkPrQaStatus(owner, repo, prNumber) {
124635
- if (!owner || !repo) return "pending";
124636
- try {
124637
- const stdout = ghSync([
124638
- "pr",
124639
- "view",
124640
- String(prNumber),
124641
- "--repo",
124642
- `${owner}/${repo}`,
124643
- "--json",
124644
- "statusCheckRollup"
124645
- ]);
124646
- const data = JSON.parse(stdout);
124647
- const checks = data.statusCheckRollup ?? [];
124648
- if (checks.length === 0) return "pass";
124649
- if (checks.every((c) => c.conclusion === "SUCCESS")) return "pass";
124650
- if (checks.some((c) => c.conclusion === "FAILURE")) return "fail";
124651
- return "pending";
124652
- } catch {
124653
- return "pass";
124654
- }
124655
- }
124656
- async function checkForBranchCommits(owner, repo, issueId) {
124657
- if (!owner || !repo) return false;
124658
- try {
124659
- const stdout = ghSync([
124660
- "api",
124661
- `repos/${owner}/${repo}/commits`,
124662
- "--jq",
124663
- ".[0].sha",
124664
- "-f",
124665
- `sha=issue-${issueId}`
124666
- ]);
124667
- return stdout.trim().length > 0;
124668
- } catch {
124669
- return false;
124670
- }
124671
- }
124672
- async function checkForArtifacts(owner, repo, issueId) {
124673
- const pr = await findPrForIssue(owner, repo, issueId);
124674
- if (pr) return true;
124675
- return checkForBranchCommits(owner, repo, issueId);
124676
- }
124677
-
124678
124581
  // lib/services/heartbeat/health.ts
124679
124582
  init_audit();
124680
124583
  init_workflow();
@@ -124682,9 +124585,6 @@ init_context3();
124682
124585
  init_labels();
124683
124586
  var GRACE_PERIOD_MS = 5 * 60 * 1e3;
124684
124587
  var DISPATCH_CONFIRMATION_TIMEOUT_MS = 2 * 60 * 1e3;
124685
- var NUDGE_MESSAGE = `You appear to have stalled. Continue working on your current task. If you are blocked or unable to proceed, call work_finish with result "blocked".`;
124686
- var MAX_STALL_NUDGES = 3;
124687
- var MAX_DISPATCH_ATTEMPTS = 5;
124688
124588
  async function auditHealthFixApplied(workspaceDir, fix, details) {
124689
124589
  if (!fix.fixed) return;
124690
124590
  await withCorrelationContext({
@@ -124716,8 +124616,7 @@ async function auditHealthFixApplied(workspaceDir, fix, details) {
124716
124616
  toLabel: details.toLabel ?? fix.issue.expectedLabel ?? null,
124717
124617
  idleMinutes: details.idleMinutes ?? null,
124718
124618
  deliveryState: details.deliveryState ?? null,
124719
- labelReverted: fix.labelReverted ?? null,
124720
- nudgeSent: fix.nudgeSent ?? false
124619
+ labelReverted: fix.labelReverted ?? null
124721
124620
  }).catch(() => {
124722
124621
  });
124723
124622
  }));
@@ -125050,218 +124949,6 @@ async function checkWorkerHealth(opts) {
125050
124949
  fixes.push(fix);
125051
124950
  continue;
125052
124951
  }
125053
- if (slot.active && sessionKey && sessions && !withinGracePeriod && isSessionAlive(sessionKey, sessions)) {
125054
- const session = sessions.get(sessionKey);
125055
- const stallThresholdMs = (opts.stallTimeoutMinutes ?? 5) * 6e4;
125056
- if (session.updatedAt == null) continue;
125057
- const sessionIdleMs = Date.now() - session.updatedAt;
125058
- if (sessionIdleMs > stallThresholdMs) {
125059
- const idleMinutes = Math.round(sessionIdleMs / 6e4);
125060
- const repoRemote = project.repoRemote ?? "";
125061
- const remoteMatch = repoRemote.match(/github\.com\/([^/]+)\/([^/.]+)/);
125062
- const diagnostic = await diagnoseStall({
125063
- projectSlug,
125064
- owner: remoteMatch?.[1] ?? "",
125065
- repo: remoteMatch?.[2] ?? "",
125066
- issueId: issueIdNum ?? 0,
125067
- sessionKey,
125068
- slotStartTime: slot.startTime ? new Date(slot.startTime).getTime() : Date.now() - sessionIdleMs,
125069
- sessionUpdatedAt: session.updatedAt,
125070
- dispatchAttemptCount: issueRuntime?.dispatchAttemptCount ?? 0
125071
- });
125072
- const evidenceActions = ["transition_to_review", "nudge_open_pr"];
125073
- const hasActionableEvidence = evidenceActions.includes(diagnostic.action);
125074
- const modelUnresponsiveMs = stallThresholdMs * MAX_STALL_NUDGES;
125075
- const slotAgeMs = slot.startTime ? Date.now() - new Date(slot.startTime).getTime() : sessionIdleMs;
125076
- const isModelUnresponsive = slotAgeMs > modelUnresponsiveMs;
125077
- const currentAttempts = issueRuntime?.dispatchAttemptCount ?? 0;
125078
- if (currentAttempts >= MAX_DISPATCH_ATTEMPTS && !hasActionableEvidence) {
125079
- const fix2 = {
125080
- issue: {
125081
- type: "diagnostic_needs_human_review",
125082
- severity: "critical",
125083
- project: project.name,
125084
- projectSlug,
125085
- role,
125086
- level,
125087
- sessionKey,
125088
- issueId: slot.issueId,
125089
- slotIndex,
125090
- message: `${role.toUpperCase()} ${level}[${slotIndex}] circuit breaker: ${currentAttempts} dispatch attempts without completion \u2014 moving to Refining`
125091
- },
125092
- fixed: false
125093
- };
125094
- if (autoFix) {
125095
- await revertLabel(fix2, expectedLabel, "Refining");
125096
- await deactivateSlot();
125097
- fix2.fixed = true;
125098
- }
125099
- await log(workspaceDir, "dispatch_circuit_breaker", {
125100
- project: project.name,
125101
- projectSlug,
125102
- role,
125103
- level,
125104
- sessionKey,
125105
- issueId: slot.issueId,
125106
- slotIndex,
125107
- dispatchAttemptCount: currentAttempts,
125108
- diagnostic: diagnostic.action,
125109
- evidence: diagnostic.evidence
125110
- }).catch(() => {
125111
- });
125112
- fixes.push(fix2);
125113
- continue;
125114
- }
125115
- if (hasActionableEvidence || isModelUnresponsive) {
125116
- if (issueIdNum) {
125117
- const runtimeUpdate = {
125118
- lastDiagnosticResult: diagnostic.evidence,
125119
- lastFailureReason: diagnostic.reason,
125120
- dispatchAttemptCount: (issueRuntime?.dispatchAttemptCount ?? 0) + 1
125121
- };
125122
- if (diagnostic.prNumber) {
125123
- const prOwner = remoteMatch?.[1] ?? "";
125124
- const prRepo = remoteMatch?.[2] ?? "";
125125
- runtimeUpdate.currentPrNumber = diagnostic.prNumber;
125126
- runtimeUpdate.currentPrUrl = `https://github.com/${prOwner}/${prRepo}/pull/${diagnostic.prNumber}`;
125127
- runtimeUpdate.currentPrState = "open";
125128
- runtimeUpdate.bindingSource = "diagnostic";
125129
- runtimeUpdate.bindingConfidence = "high";
125130
- runtimeUpdate.boundAt = (/* @__PURE__ */ new Date()).toISOString();
125131
- }
125132
- await updateIssueRuntime(workspaceDir, projectSlug, String(issueIdNum), runtimeUpdate).catch(() => {
125133
- });
125134
- }
125135
- const diagnosticType = `diagnostic_${diagnostic.action}`;
125136
- const fix2 = {
125137
- issue: {
125138
- type: diagnosticType,
125139
- severity: diagnostic.action === "needs_human_review" ? "critical" : "warning",
125140
- project: project.name,
125141
- projectSlug,
125142
- role,
125143
- level,
125144
- sessionKey,
125145
- issueId: slot.issueId,
125146
- slotIndex,
125147
- message: `${role.toUpperCase()} ${level}[${slotIndex}] stall diagnosed: ${diagnostic.action} \u2014 ${diagnostic.evidence}`
125148
- },
125149
- fixed: false
125150
- };
125151
- if (autoFix) {
125152
- switch (diagnostic.action) {
125153
- case "transition_to_review": {
125154
- const targetLabel = role === "tester" ? "Done" : role === "reviewer" ? "To Test" : "To Review";
125155
- await revertLabel(fix2, expectedLabel, targetLabel);
125156
- if (!fix2.labelRevertFailed) {
125157
- await deactivateSlot();
125158
- if (role === "tester" && issueIdNum) {
125159
- await provider.closeIssue(issueIdNum).catch(() => {
125160
- });
125161
- }
125162
- fix2.fixed = true;
125163
- }
125164
- break;
125165
- }
125166
- case "redispatch_same_level":
125167
- case "nudge_open_pr":
125168
- case "retry_infra":
125169
- await revertLabel(fix2, expectedLabel, slotQueueLabel);
125170
- if (!fix2.labelRevertFailed) {
125171
- await deactivateSlot();
125172
- fix2.fixed = true;
125173
- }
125174
- break;
125175
- case "escalate_level":
125176
- await revertLabel(fix2, expectedLabel, slotQueueLabel);
125177
- if (!fix2.labelRevertFailed) {
125178
- await deactivateSlot();
125179
- fix2.fixed = true;
125180
- }
125181
- break;
125182
- case "needs_human_review":
125183
- await revertLabel(fix2, expectedLabel, "Refining");
125184
- await deactivateSlot();
125185
- fix2.fixed = true;
125186
- break;
125187
- case "log_infra":
125188
- await revertLabel(fix2, expectedLabel, "Refining");
125189
- await deactivateSlot();
125190
- fix2.fixed = true;
125191
- break;
125192
- }
125193
- }
125194
- await log(workspaceDir, "stall_diagnostic", {
125195
- project: project.name,
125196
- projectSlug,
125197
- role,
125198
- level,
125199
- sessionKey,
125200
- issueId: slot.issueId,
125201
- slotIndex,
125202
- idleMinutes,
125203
- diagnostic: diagnostic.action,
125204
- reason: diagnostic.reason,
125205
- evidence: diagnostic.evidence,
125206
- fastPath: hasActionableEvidence,
125207
- dispatchAttemptCount: (issueRuntime?.dispatchAttemptCount ?? 0) + 1
125208
- }).catch(() => {
125209
- });
125210
- fixes.push(fix2);
125211
- continue;
125212
- }
125213
- const fix = {
125214
- issue: {
125215
- type: "session_stalled",
125216
- severity: "critical",
125217
- project: project.name,
125218
- projectSlug,
125219
- role,
125220
- level,
125221
- sessionKey,
125222
- issueId: slot.issueId,
125223
- slotIndex,
125224
- message: `${role.toUpperCase()} ${level}[${slotIndex}] session idle ${idleMinutes}m \u2014 no deliverables yet, sending nudge`
125225
- },
125226
- fixed: false
125227
- };
125228
- if (autoFix) {
125229
- sendToAgent(sessionKey, NUDGE_MESSAGE, {
125230
- agentId: opts.agentId,
125231
- projectName: project.name,
125232
- issueId: issueIdNum,
125233
- role,
125234
- level,
125235
- slotIndex,
125236
- workspaceDir,
125237
- runCommand: opts.runCommand,
125238
- runtime: opts.runtime
125239
- });
125240
- fix.nudgeSent = true;
125241
- fix.fixed = true;
125242
- await auditHealthFixApplied(workspaceDir, fix, {
125243
- action: "nudge_session",
125244
- idleMinutes,
125245
- deliveryState
125246
- });
125247
- }
125248
- await log(workspaceDir, "session_stalled", {
125249
- project: project.name,
125250
- projectSlug,
125251
- role,
125252
- level,
125253
- sessionKey,
125254
- issueId: slot.issueId,
125255
- slotIndex,
125256
- idleMinutes,
125257
- deliveryState,
125258
- action: "nudge"
125259
- }).catch(() => {
125260
- });
125261
- fixes.push(fix);
125262
- continue;
125263
- }
125264
- }
125265
124952
  if (slot.active && slot.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) {
125266
124953
  const hours = (Date.now() - new Date(slot.startTime).getTime()) / 36e5;
125267
124954
  if (hours > staleWorkerHours) {
@@ -125295,6 +124982,32 @@ async function checkWorkerHealth(opts) {
125295
124982
  fixes.push(fix);
125296
124983
  }
125297
124984
  }
124985
+ if (slot.active && issueIdNum && issue2 && currentLabel === expectedLabel && autoFix) {
124986
+ try {
124987
+ const prStatus = await provider.getPrStatus(issueIdNum);
124988
+ if (prStatus.url && prStatus.state !== PrState.MERGED && prStatus.state !== PrState.CLOSED && prStatus.state !== PrState.CHANGES_REQUESTED && prStatus.state !== PrState.HAS_COMMENTS && prStatus.currentIssueMatch !== false) {
124989
+ const rule = getCompletionRule(workflow, role, "done");
124990
+ if (rule && rule.to !== expectedLabel) {
124991
+ await resilientLabelTransition(provider, issueIdNum, expectedLabel, rule.to);
124992
+ await deactivateSlot();
124993
+ await log(workspaceDir, "health_transition_to_review", {
124994
+ project: project.name,
124995
+ projectSlug,
124996
+ role,
124997
+ level,
124998
+ issueId: slot.issueId,
124999
+ sessionKey,
125000
+ slotIndex,
125001
+ fromLabel: expectedLabel,
125002
+ toLabel: rule.to,
125003
+ prUrl: prStatus.url
125004
+ }).catch(() => {
125005
+ });
125006
+ }
125007
+ }
125008
+ } catch {
125009
+ }
125010
+ }
125298
125011
  if (!slot.active && issue2 && currentLabel === expectedLabel) {
125299
125012
  const fix = {
125300
125013
  issue: {
@@ -125574,8 +125287,7 @@ function createHealthTool(ctx) {
125574
125287
  role,
125575
125288
  sessions,
125576
125289
  autoFix: fix,
125577
- provider,
125578
- runCommand: ctx.runCommand
125290
+ provider
125579
125291
  });
125580
125292
  issues.push(...healthFixes.map((f3) => ({ ...f3, project: project.name, role })));
125581
125293
  const orphanFixes = await scanOrphanedLabels({
@@ -130831,30 +130543,52 @@ async function projectTick(opts) {
130831
130543
  }
130832
130544
  if (role === "reviewer" || role === "tester") {
130833
130545
  const issueRuntime = getIssueRuntime(fresh, issue2.iid);
130834
- const prSelector = getCanonicalPrSelector(fresh, issue2.iid);
130546
+ let prSelector = getCanonicalPrSelector(fresh, issue2.iid);
130835
130547
  if (!prSelector?.prNumber) {
130836
- const feedbackLabel = getFeedbackQueueLabel(workflow);
130837
- if (!dryRun && feedbackLabel && feedbackLabel !== currentLabel) {
130838
- try {
130839
- await provider.transitionLabel(issue2.iid, currentLabel, feedbackLabel);
130840
- await log(workspaceDir, "queue_pr_guard", {
130841
- project: project.name,
130842
- projectSlug,
130843
- issueId: issue2.iid,
130844
- role,
130845
- from: currentLabel,
130846
- to: feedbackLabel,
130847
- prState: issueRuntime?.currentPrState ?? null,
130848
- prUrl: issueRuntime?.currentPrUrl ?? null,
130849
- prNumber: null,
130850
- currentIssueMatch: null,
130851
- reason: "missing_canonical_pr"
130548
+ const fallbackStatus = await provider.getPrStatus(issue2.iid).catch(() => null);
130549
+ const hasFallbackPr = !!fallbackStatus?.url && !!fallbackStatus.number && fallbackStatus.state !== PrState.MERGED && fallbackStatus.state !== PrState.CLOSED && fallbackStatus.currentIssueMatch !== false;
130550
+ if (hasFallbackPr && fallbackStatus?.number) {
130551
+ if (!dryRun) {
130552
+ await updateIssueRuntime(workspaceDir, projectSlug, issue2.iid, {
130553
+ currentPrNumber: fallbackStatus.number,
130554
+ currentPrUrl: fallbackStatus.url,
130555
+ currentPrState: fallbackStatus.state ?? null
130556
+ }).catch(() => {
130852
130557
  });
130853
- } catch {
130854
130558
  }
130559
+ const runtimeKey = String(issue2.iid);
130560
+ fresh.issueRuntime = fresh.issueRuntime ?? {};
130561
+ fresh.issueRuntime[runtimeKey] = {
130562
+ ...fresh.issueRuntime[runtimeKey] ?? {},
130563
+ currentPrNumber: fallbackStatus.number,
130564
+ currentPrUrl: fallbackStatus.url ?? null,
130565
+ currentPrState: fallbackStatus.state ?? null
130566
+ };
130567
+ prSelector = { prNumber: fallbackStatus.number };
130568
+ } else {
130569
+ const feedbackLabel = getFeedbackQueueLabel(workflow);
130570
+ if (!dryRun && feedbackLabel && feedbackLabel !== currentLabel) {
130571
+ try {
130572
+ await provider.transitionLabel(issue2.iid, currentLabel, feedbackLabel);
130573
+ await log(workspaceDir, "queue_pr_guard", {
130574
+ project: project.name,
130575
+ projectSlug,
130576
+ issueId: issue2.iid,
130577
+ role,
130578
+ from: currentLabel,
130579
+ to: feedbackLabel,
130580
+ prState: issueRuntime?.currentPrState ?? null,
130581
+ prUrl: issueRuntime?.currentPrUrl ?? null,
130582
+ prNumber: null,
130583
+ currentIssueMatch: null,
130584
+ reason: "missing_canonical_pr"
130585
+ });
130586
+ } catch {
130587
+ }
130588
+ }
130589
+ skipped.push({ role, reason: "No canonical bound PR for review/test cycle" });
130590
+ continue;
130855
130591
  }
130856
- skipped.push({ role, reason: "No canonical bound PR for review/test cycle" });
130857
- continue;
130858
130592
  }
130859
130593
  const prStatus = await provider.getPrStatus(issue2.iid, prSelector);
130860
130594
  const hasReviewablePr = !!prStatus.url && prStatus.state !== PrState.MERGED && prStatus.state !== PrState.CLOSED && prStatus.currentIssueMatch !== false;
@@ -131714,10 +131448,6 @@ async function performHealthPass(workspaceDir, projectSlug, project, sessions, p
131714
131448
  autoFix: true,
131715
131449
  provider,
131716
131450
  staleWorkerHours,
131717
- stallTimeoutMinutes,
131718
- runCommand,
131719
- runtime,
131720
- agentId,
131721
131451
  workflow: resolvedConfig?.workflow,
131722
131452
  dispatchConfirmTimeoutMs: resolvedConfig?.timeouts?.dispatchConfirmTimeoutMs
131723
131453
  });
@@ -138902,14 +138632,16 @@ function buildTopicDeepLink(chatId, topicId) {
138902
138632
  function buildDmAck(projectName, topicLink, language = "pt") {
138903
138633
  return BOOTSTRAP_MESSAGES.registered[language](projectName, topicLink);
138904
138634
  }
138905
- function buildTopicKickoff(projectName, idea) {
138906
- return [
138907
- `\u{1F9F1} Projeto registrado automaticamente pela Fabrica.`,
138908
- `Projeto: ${projectName}`,
138909
- "",
138910
- "Resumo do pedido inicial:",
138911
- idea
138912
- ].join("\n");
138635
+ function buildTopicKickoff(projectName, idea, language = "pt") {
138636
+ const header = language === "en" ? `\u{1F9F1} Project automatically registered by Fabrica.
138637
+ Project: ${projectName}
138638
+
138639
+ Original request summary:` : `\u{1F9F1} Projeto registrado automaticamente pela Fabrica.
138640
+ Projeto: ${projectName}
138641
+
138642
+ Resumo do pedido inicial:`;
138643
+ return `${header}
138644
+ ${idea}`;
138913
138645
  }
138914
138646
  async function sendTelegramText(ctx, target, message, opts) {
138915
138647
  const sendOpts = {
@@ -139199,7 +138931,8 @@ Erro: ${result.error ?? "erro desconhecido"}`
139199
138931
  accountId: telegramConfig.projectsForumAccountId ?? void 0
139200
138932
  }
139201
138933
  });
139202
- await sendTelegramText(ctx, projectChannelId, buildTopicKickoff(resolvedProjectName, request.rawIdea), {
138934
+ const sessionLang = currentSession?.language ?? "pt";
138935
+ await sendTelegramText(ctx, projectChannelId, buildTopicKickoff(resolvedProjectName, request.rawIdea, sessionLang), {
139203
138936
  accountId: telegramConfig.projectsForumAccountId,
139204
138937
  messageThreadId
139205
138938
  });
@@ -139218,7 +138951,6 @@ Erro: ${result.error ?? "erro desconhecido"}`
139218
138951
  logBootstrapWarning(ctx, `[telegram-bootstrap] immediate projectTick failed: ${error48 instanceof Error ? error48.message : String(error48)}`);
139219
138952
  });
139220
138953
  }
139221
- const sessionLang = currentSession?.language ?? "pt";
139222
138954
  await sendTelegramText(ctx, conversationId, buildDmAck(resolvedProjectName, buildTopicDeepLink(String(projectChannelId), messageThreadId), sessionLang));
139223
138955
  await upsertTelegramBootstrapSession(workspaceDir, {
139224
138956
  conversationId,
@@ -139738,6 +139470,36 @@ function registerGatewayLifecycleHook(api, ctx) {
139738
139470
 
139739
139471
  // lib/dispatch/subagent-lifecycle-hook.ts
139740
139472
  init_audit();
139473
+
139474
+ // lib/dispatch/reactive-dispatch-hook.ts
139475
+ var COMPLETION_TOOLS = /* @__PURE__ */ new Set(["work_finish", "review_submit"]);
139476
+ var spawnTimes = /* @__PURE__ */ new Map();
139477
+ function getSpawnTime(sessionKey) {
139478
+ return spawnTimes.get(sessionKey);
139479
+ }
139480
+ function clearSpawnTime(sessionKey) {
139481
+ spawnTimes.delete(sessionKey);
139482
+ }
139483
+ function registerReactiveDispatchHooks(api, ctx) {
139484
+ api.on("after_tool_call", async (event, _eventCtx) => {
139485
+ if (!COMPLETION_TOOLS.has(event.toolName)) return;
139486
+ ctx.runtime?.system.requestHeartbeatNow({ reason: "work_finish", coalesceMs: 2e3 });
139487
+ });
139488
+ api.on("agent_end", async (_event, eventCtx) => {
139489
+ const sessionKey = eventCtx.sessionKey;
139490
+ if (!sessionKey) return;
139491
+ const parsed = parseFabricaSessionKey(sessionKey);
139492
+ if (!parsed) return;
139493
+ ctx.runtime?.system.requestHeartbeatNow({ reason: "agent_end", coalesceMs: 2e3 });
139494
+ });
139495
+ api.on("subagent_spawned", async (event, _eventCtx) => {
139496
+ const sessionKey = event.childSessionKey;
139497
+ if (!sessionKey) return;
139498
+ spawnTimes.set(sessionKey, Date.now());
139499
+ });
139500
+ }
139501
+
139502
+ // lib/dispatch/subagent-lifecycle-hook.ts
139741
139503
  function registerSubagentLifecycleHook(api, ctx) {
139742
139504
  const workspaceDir = resolveWorkspaceDir(ctx.config);
139743
139505
  if (!workspaceDir) return;
@@ -139747,11 +139509,15 @@ function registerSubagentLifecycleHook(api, ctx) {
139747
139509
  const parsed = parseFabricaSessionKey(sessionKey);
139748
139510
  if (!parsed) return;
139749
139511
  const { projectName, role } = parsed;
139512
+ const spawnTime = getSpawnTime(sessionKey);
139513
+ clearSpawnTime(sessionKey);
139514
+ const durationMs = spawnTime != null ? Date.now() - spawnTime : void 0;
139750
139515
  await log(workspaceDir, "subagent_ended", {
139751
139516
  sessionKey,
139752
139517
  project: projectName,
139753
139518
  role,
139754
- outcome: event.outcome ?? "unknown"
139519
+ outcome: event.outcome ?? "unknown",
139520
+ ...durationMs != null ? { durationMs } : {}
139755
139521
  }).catch(() => {
139756
139522
  });
139757
139523
  ctx.logger.info(
@@ -139815,6 +139581,23 @@ function registerModelResolveHook(api, ctx) {
139815
139581
  });
139816
139582
  }
139817
139583
 
139584
+ // lib/dispatch/worker-context-hook.ts
139585
+ var WORK_FINISH_CONTEXT = `## Task Completion
139586
+
139587
+ When you have finished your task, you MUST call the \`work_finish\` tool to signal completion.
139588
+ Do NOT rely on your session ending automatically \u2014 you must explicitly call \`work_finish\`.
139589
+ This is required for the pipeline to advance to the next stage.
139590
+ `;
139591
+ function registerWorkerContextHook(api, _ctx) {
139592
+ api.on("before_agent_start", async (_event, eventCtx) => {
139593
+ const sessionKey = eventCtx.sessionKey;
139594
+ if (!sessionKey) return;
139595
+ const parsed = parseFabricaSessionKey(sessionKey);
139596
+ if (!parsed) return;
139597
+ return { prependSystemContext: WORK_FINISH_CONTEXT };
139598
+ });
139599
+ }
139600
+
139818
139601
  // index.ts
139819
139602
  var plugin = {
139820
139603
  id: "fabrica",
@@ -139955,8 +139738,10 @@ var plugin = {
139955
139738
  registerGatewayLifecycleHook(api, ctx);
139956
139739
  registerSubagentLifecycleHook(api, ctx);
139957
139740
  registerModelResolveHook(api, ctx);
139741
+ registerWorkerContextHook(api, ctx);
139742
+ registerReactiveDispatchHooks(api, ctx);
139958
139743
  ctx.logger.info(
139959
- "Fabrica plugin registered (25 tools, 1 CLI command group, 1 service, 7 hooks total: bootstrap, telegram-dm bootstrap, attachment, gateway lifecycle, subagent lifecycle, model-resolve, optional GitHub webhook route)"
139744
+ "Fabrica plugin registered (25 tools, 1 CLI command group, 1 service, 9 hooks total: bootstrap, telegram-dm bootstrap, attachment, gateway lifecycle, subagent lifecycle, model-resolve, worker-context, reactive-dispatch, optional GitHub webhook route)"
139960
139745
  );
139961
139746
  }
139962
139747
  };