@mestreyoda/fabrica 0.2.7 → 0.2.11

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
@@ -87701,7 +87701,7 @@ var require_gaxios = __commonJS({
87701
87701
  var retry_js_1 = require_retry();
87702
87702
  var stream_1 = __require("stream");
87703
87703
  var interceptor_js_1 = require_interceptor();
87704
- var randomUUID4 = async () => globalThis.crypto?.randomUUID() || (await import("crypto")).randomUUID();
87704
+ var randomUUID6 = async () => globalThis.crypto?.randomUUID() || (await import("crypto")).randomUUID();
87705
87705
  var HTTP_STATUS_NO_CONTENT = 204;
87706
87706
  var Gaxios = class {
87707
87707
  agentCache = /* @__PURE__ */ new Map();
@@ -87974,7 +87974,7 @@ var require_gaxios = __commonJS({
87974
87974
  */
87975
87975
  ["Blob", "File", "FormData"].includes(opts.data?.constructor?.name || "");
87976
87976
  if (opts.multipart?.length) {
87977
- const boundary = await randomUUID4();
87977
+ const boundary = await randomUUID6();
87978
87978
  preparedHeaders.set("content-type", `multipart/related; boundary=${boundary}`);
87979
87979
  opts.body = stream_1.Readable.from(this.getMultipartRequest(opts.multipart, boundary));
87980
87980
  } else if (shouldDirectlyPassData) {
@@ -110860,18 +110860,6 @@ function mergeConfig(base, overlay, traceOpts) {
110860
110860
  if (base.instance || overlay.instance) {
110861
110861
  merged.instance = { ...base.instance, ...overlay.instance };
110862
110862
  }
110863
- if (base.providers || overlay.providers) {
110864
- merged.providers = {
110865
- github: base.providers?.github || overlay.providers?.github ? {
110866
- ...base.providers?.github,
110867
- ...overlay.providers?.github,
110868
- authProfiles: base.providers?.github?.authProfiles || overlay.providers?.github?.authProfiles ? {
110869
- ...base.providers?.github?.authProfiles,
110870
- ...overlay.providers?.github?.authProfiles
110871
- } : void 0
110872
- } : void 0
110873
- };
110874
- }
110875
110863
  if (traceOpts) {
110876
110864
  const { baseLabel, overlayLabel } = traceOpts;
110877
110865
  const trace2 = {};
@@ -111116,8 +111104,11 @@ function parseLegacyFlatState(worker, role) {
111116
111104
  issueId: worker.issueId,
111117
111105
  sessionKey,
111118
111106
  startTime: worker.startTime,
111107
+ dispatchCycleId: worker.dispatchCycleId ?? null,
111108
+ dispatchRunId: worker.dispatchRunId ?? null,
111119
111109
  previousLabel: worker.previousLabel ?? null,
111120
- name: worker.name ?? worker.slotName
111110
+ name: worker.name ?? worker.slotName,
111111
+ lastIssueId: worker.lastIssueId ?? null
111121
111112
  };
111122
111113
  return { levels: { [migratedLevel]: [slot] } };
111123
111114
  }
@@ -111133,8 +111124,11 @@ function parseOldSlotState(worker, role) {
111133
111124
  issueId: s2.issueId,
111134
111125
  sessionKey: s2.sessionKey,
111135
111126
  startTime: s2.startTime,
111127
+ dispatchCycleId: s2.dispatchCycleId ?? null,
111128
+ dispatchRunId: s2.dispatchRunId ?? null,
111136
111129
  previousLabel: s2.previousLabel ?? null,
111137
- name: s2.name ?? s2.slotName
111130
+ name: s2.name ?? s2.slotName,
111131
+ lastIssueId: s2.lastIssueId ?? null
111138
111132
  });
111139
111133
  }
111140
111134
  return { levels };
@@ -111152,8 +111146,11 @@ function parseLevelState(worker, role) {
111152
111146
  issueId: s2.issueId,
111153
111147
  sessionKey: s2.sessionKey,
111154
111148
  startTime: s2.startTime,
111149
+ dispatchCycleId: s2.dispatchCycleId ?? null,
111150
+ dispatchRunId: s2.dispatchRunId ?? null,
111155
111151
  previousLabel: s2.previousLabel ?? null,
111156
- name: s2.name ?? s2.slotName
111152
+ name: s2.name ?? s2.slotName,
111153
+ lastIssueId: s2.lastIssueId ?? null
111157
111154
  });
111158
111155
  }
111159
111156
  }
@@ -111330,8 +111327,8 @@ import fsSync from "node:fs";
111330
111327
  import path5 from "node:path";
111331
111328
  import { fileURLToPath as fileURLToPath3 } from "node:url";
111332
111329
  function getCurrentVersion() {
111333
- if ("0.2.7") {
111334
- return "0.2.7";
111330
+ if ("0.2.11") {
111331
+ return "0.2.11";
111335
111332
  }
111336
111333
  try {
111337
111334
  const pkgPath = path5.join(THIS_DIR, "..", "..", "package.json");
@@ -112333,6 +112330,7 @@ async function readWorkflowFile(dir) {
112333
112330
  try {
112334
112331
  const content = await fs9.readFile(filePath, "utf-8");
112335
112332
  const parsed = import_yaml2.default.parse(content);
112333
+ rejectPluginOnlyKeys(parsed, filePath);
112336
112334
  if (parsed) validateConfig(parsed);
112337
112335
  return parsed;
112338
112336
  } catch (err) {
@@ -112345,6 +112343,7 @@ async function readLegacyConfigFile(dir) {
112345
112343
  try {
112346
112344
  const content = await fs9.readFile(filePath, "utf-8");
112347
112345
  const parsed = import_yaml2.default.parse(content);
112346
+ rejectPluginOnlyKeys(parsed, filePath);
112348
112347
  if (parsed) validateConfig(parsed);
112349
112348
  return parsed;
112350
112349
  } catch (err) {
@@ -112352,6 +112351,14 @@ async function readLegacyConfigFile(dir) {
112352
112351
  throw new Error(formatConfigReadError(filePath, err, "legacy config.yaml"));
112353
112352
  }
112354
112353
  }
112354
+ function rejectPluginOnlyKeys(raw, filePath) {
112355
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
112356
+ const forbidden = PLUGIN_ONLY_KEYS.filter((key) => key in raw);
112357
+ if (forbidden.length === 0) return;
112358
+ throw new Error(
112359
+ `Invalid workflow config at ${filePath}: plugin-only keys are not allowed in workflow.yaml/config.yaml (${forbidden.join(", ")}). Configure them in pluginConfig/openclaw.json instead.`
112360
+ );
112361
+ }
112355
112362
  async function readLegacyWorkflowJson(dir) {
112356
112363
  const filePath = path9.join(dir, "workflow.json");
112357
112364
  try {
@@ -112374,7 +112381,7 @@ function formatConfigReadError(filePath, err, kind) {
112374
112381
  const prefix = invalidKinds.has(err?.name ?? "") ? `Invalid ${kind}` : `Failed to read ${kind}`;
112375
112382
  return `${prefix} at ${filePath}: ${message}`;
112376
112383
  }
112377
- var import_yaml2, DEFAULT_MAX_WORKERS_PER_LEVEL;
112384
+ var import_yaml2, DEFAULT_MAX_WORKERS_PER_LEVEL, PLUGIN_ONLY_KEYS;
112378
112385
  var init_loader = __esm({
112379
112386
  "lib/config/loader.ts"() {
112380
112387
  "use strict";
@@ -112387,6 +112394,13 @@ var init_loader = __esm({
112387
112394
  init_migrate_layout();
112388
112395
  init_workflow_policy();
112389
112396
  DEFAULT_MAX_WORKERS_PER_LEVEL = 2;
112397
+ PLUGIN_ONLY_KEYS = [
112398
+ "providers",
112399
+ "telegram",
112400
+ "work_heartbeat",
112401
+ "notifications",
112402
+ "projectExecution"
112403
+ ];
112390
112404
  }
112391
112405
  });
112392
112406
 
@@ -112519,9 +112533,9 @@ function resolveNotifyChannel(issueLabels, channels) {
112519
112533
  const channelName = value.slice(colonIdx + 1);
112520
112534
  return channels.find(
112521
112535
  (ch) => ch.channel === channelType && (ch.name === channelName || String(channels.indexOf(ch)) === channelName)
112522
- ) ?? channels[0];
112536
+ );
112523
112537
  }
112524
- return channels.find((ch) => ch.channelId === value) ?? channels[0];
112538
+ return channels.find((ch) => ch.channelId === value);
112525
112539
  }
112526
112540
  return channels[0];
112527
112541
  }
@@ -112742,7 +112756,7 @@ function validateWorkflowIntegrity(workflow) {
112742
112756
  }
112743
112757
  return errors;
112744
112758
  }
112745
- var STATE_TYPES, TransitionTargetSchema, StateConfigSchema, WorkflowConfigSchema, ModelEntrySchema, EffortLevelSchema, RoleOverrideSchema, TimeoutConfigSchema, InstanceConfigSchema, GitHubAppProfileSchema, ProvidersConfigSchema, FabricaConfigSchema;
112759
+ var STATE_TYPES, TransitionTargetSchema, StateConfigSchema, WorkflowConfigSchema, ModelEntrySchema, EffortLevelSchema, RoleOverrideSchema, TimeoutConfigSchema, InstanceConfigSchema, GitHubAppProfileSchema, ProvidersConfigSchema, FabricaConfigSchema, HeartbeatPluginConfigSchema, TelegramPluginConfigSchema, NotificationPluginConfigSchema, FabricaPluginConfigSchema;
112746
112760
  var init_schema = __esm({
112747
112761
  "lib/config/schema.ts"() {
112748
112762
  "use strict";
@@ -112850,9 +112864,30 @@ var init_schema = __esm({
112850
112864
  roles: external_exports.record(external_exports.string(), RoleOverrideSchema).optional(),
112851
112865
  workflow: WorkflowConfigSchema.partial().optional(),
112852
112866
  timeouts: TimeoutConfigSchema,
112853
- instance: InstanceConfigSchema,
112854
- providers: ProvidersConfigSchema
112867
+ instance: InstanceConfigSchema
112855
112868
  });
112869
+ HeartbeatPluginConfigSchema = external_exports.object({
112870
+ enabled: external_exports.boolean().optional(),
112871
+ intervalSeconds: external_exports.number().int().positive().optional(),
112872
+ maxPickupsPerTick: external_exports.number().int().positive().optional()
112873
+ }).optional();
112874
+ TelegramPluginConfigSchema = external_exports.object({
112875
+ bootstrapDmEnabled: external_exports.boolean().optional(),
112876
+ projectsForumChatId: external_exports.string().min(1).optional(),
112877
+ projectsForumAccountId: external_exports.string().min(1).optional(),
112878
+ opsChatId: external_exports.string().min(1).optional()
112879
+ }).optional();
112880
+ NotificationPluginConfigSchema = external_exports.object({
112881
+ workerStart: external_exports.boolean().optional(),
112882
+ workerComplete: external_exports.boolean().optional()
112883
+ }).optional();
112884
+ FabricaPluginConfigSchema = external_exports.object({
112885
+ work_heartbeat: HeartbeatPluginConfigSchema,
112886
+ projectExecution: external_exports.enum(["parallel", "sequential"]).optional(),
112887
+ notifications: NotificationPluginConfigSchema,
112888
+ telegram: TelegramPluginConfigSchema,
112889
+ providers: ProvidersConfigSchema
112890
+ }).passthrough();
112856
112891
  }
112857
112892
  });
112858
112893
 
@@ -115455,7 +115490,7 @@ var receive_exports = {};
115455
115490
  __export(receive_exports, {
115456
115491
  receiveStep: () => receiveStep
115457
115492
  });
115458
- import { randomUUID as randomUUID2 } from "node:crypto";
115493
+ import { randomUUID as randomUUID3 } from "node:crypto";
115459
115494
  var receiveStep;
115460
115495
  var init_receive = __esm({
115461
115496
  "lib/intake/steps/receive.ts"() {
@@ -115466,7 +115501,7 @@ var init_receive = __esm({
115466
115501
  async execute(payload) {
115467
115502
  return {
115468
115503
  ...payload,
115469
- session_id: payload.session_id || randomUUID2(),
115504
+ session_id: payload.session_id || randomUUID3(),
115470
115505
  timestamp: payload.timestamp || (/* @__PURE__ */ new Date()).toISOString(),
115471
115506
  step: "receive"
115472
115507
  };
@@ -116305,12 +116340,10 @@ function createPluginContext(api) {
116305
116340
  const logger6 = getLogger({ plugin: "fabrica" });
116306
116341
  const rawConfig = api.pluginConfig;
116307
116342
  if (rawConfig && Object.keys(rawConfig).length > 0) {
116308
- const result = FabricaConfigSchema.safeParse(rawConfig);
116343
+ const result = FabricaPluginConfigSchema.safeParse(rawConfig);
116309
116344
  if (!result.success) {
116310
- logger6.warn(
116311
- { errors: result.error.issues.map((i2) => `${i2.path.join(".")}: ${i2.message}`) },
116312
- "pluginConfig validation failed \u2014 using config as-is, some fields may be ignored"
116313
- );
116345
+ const issues = result.error.issues.map((i2) => `${i2.path.join(".")}: ${i2.message}`);
116346
+ throw new Error(`pluginConfig validation failed: ${issues.join("; ")}`);
116314
116347
  }
116315
116348
  }
116316
116349
  return {
@@ -116352,7 +116385,9 @@ function emptySlot() {
116352
116385
  active: false,
116353
116386
  issueId: null,
116354
116387
  sessionKey: null,
116355
- startTime: null
116388
+ startTime: null,
116389
+ dispatchCycleId: null,
116390
+ dispatchRunId: null
116356
116391
  };
116357
116392
  }
116358
116393
  function emptyRoleWorkerState(levelMaxWorkers) {
@@ -116850,6 +116885,8 @@ async function activateWorker(workspaceDir, slugOrChannelId, role, params) {
116850
116885
  issueId: params.issueId,
116851
116886
  sessionKey: params.sessionKey ?? slots[idx].sessionKey,
116852
116887
  startTime: params.startTime ?? (/* @__PURE__ */ new Date()).toISOString(),
116888
+ dispatchCycleId: params.dispatchCycleId ?? null,
116889
+ dispatchRunId: params.dispatchRunId ?? null,
116853
116890
  previousLabel: params.previousLabel ?? null,
116854
116891
  name: params.name ?? slots[idx].name,
116855
116892
  lastIssueId: null
@@ -116887,6 +116924,8 @@ async function deactivateWorker(workspaceDir, slugOrChannelId, role, opts) {
116887
116924
  issueId: null,
116888
116925
  sessionKey: slot.sessionKey,
116889
116926
  startTime: null,
116927
+ dispatchCycleId: slot.dispatchCycleId ?? null,
116928
+ dispatchRunId: slot.dispatchRunId ?? null,
116890
116929
  previousLabel: null,
116891
116930
  name: slot.name,
116892
116931
  lastIssueId: slot.issueId
@@ -116913,6 +116952,37 @@ async function updateIssueRuntime(workspaceDir, slugOrChannelId, issueId, update
116913
116952
  });
116914
116953
  return data;
116915
116954
  }
116955
+ async function bindDispatchRunIdBySessionKey(workspaceDir, sessionKey, runId) {
116956
+ const { result } = await withProjectsMutation(workspaceDir, (data) => {
116957
+ for (const [slug, project] of Object.entries(data.projects)) {
116958
+ for (const [role, roleWorker] of Object.entries(project.workers ?? {})) {
116959
+ for (const [level, slots] of Object.entries(roleWorker.levels ?? {})) {
116960
+ for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
116961
+ const slot = slots[slotIndex];
116962
+ if (!slot.active || !slot.issueId || slot.sessionKey !== sessionKey) continue;
116963
+ slot.dispatchRunId = runId;
116964
+ project.issueRuntime ??= {};
116965
+ const issueKey = String(slot.issueId);
116966
+ project.issueRuntime[issueKey] = {
116967
+ ...project.issueRuntime[issueKey] ?? {},
116968
+ dispatchRunId: runId,
116969
+ lastSessionKey: sessionKey
116970
+ };
116971
+ return {
116972
+ slug,
116973
+ issueId: Number(slot.issueId),
116974
+ role,
116975
+ level,
116976
+ slotIndex
116977
+ };
116978
+ }
116979
+ }
116980
+ }
116981
+ }
116982
+ return null;
116983
+ });
116984
+ return result;
116985
+ }
116916
116986
  async function clearIssueRuntime(workspaceDir, slugOrChannelId, issueId) {
116917
116987
  const { data } = await withProjectsMutation(workspaceDir, (data2) => {
116918
116988
  const slug = resolveProjectSlug(data2, slugOrChannelId);
@@ -116944,23 +117014,18 @@ function findIssueBySessionKey(data, sessionKey) {
116944
117014
  if (slot.sessionKey !== sessionKey) continue;
116945
117015
  const issueId = slot.issueId ?? slot.lastIssueId;
116946
117016
  if (!issueId) continue;
117017
+ const runtime = getIssueRuntime(project, issueId);
117018
+ if (!slot.dispatchCycleId || !runtime?.lastDispatchCycleId || slot.dispatchCycleId !== runtime.lastDispatchCycleId) {
117019
+ continue;
117020
+ }
116947
117021
  return {
116948
117022
  slug,
116949
117023
  issueId: Number(issueId),
116950
- runtime: getIssueRuntime(project, issueId)
117024
+ runtime
116951
117025
  };
116952
117026
  }
116953
117027
  }
116954
117028
  }
116955
- for (const [issueId, runtime] of Object.entries(project.issueRuntime ?? {})) {
116956
- if (runtime.lastSessionKey === sessionKey) {
116957
- return {
116958
- slug,
116959
- issueId: Number(issueId),
116960
- runtime
116961
- };
116962
- }
116963
- }
116964
117029
  }
116965
117030
  return null;
116966
117031
  }
@@ -117839,7 +117904,7 @@ var GitLabProvider = class {
117839
117904
  return null;
117840
117905
  }
117841
117906
  }
117842
- async getPrDetails(_issueId) {
117907
+ async getPrDetails(_issueId, _selector) {
117843
117908
  return null;
117844
117909
  }
117845
117910
  async healthCheck() {
@@ -118396,7 +118461,7 @@ Bootstrapped by Fabrica.
118396
118461
  if (!pr?.number || !pr?.url) return null;
118397
118462
  let state;
118398
118463
  if (pr.state === "closed" && pr.mergedAt) {
118399
- state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.MERGED;
118464
+ state = PrState.MERGED;
118400
118465
  } else if (pr.state === "closed") {
118401
118466
  state = PrState.CLOSED;
118402
118467
  } else if (pr.reviewDecision === "APPROVED") {
@@ -118712,7 +118777,7 @@ Bootstrapped by Fabrica.
118712
118777
  if (merged.length > 0) {
118713
118778
  merged.sort((a, b) => new Date(b.mergedAt ?? 0).getTime() - new Date(a.mergedAt ?? 0).getTime());
118714
118779
  const pr = merged[0];
118715
- const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.MERGED;
118780
+ const state = PrState.MERGED;
118716
118781
  return {
118717
118782
  number: pr.number,
118718
118783
  nodeId: pr.id,
@@ -118747,34 +118812,49 @@ Bootstrapped by Fabrica.
118747
118812
  }
118748
118813
  return { state: PrState.CLOSED, url: null };
118749
118814
  }
118750
- async getPrDetails(issueId) {
118815
+ async getPrDetails(issueId, selector) {
118751
118816
  try {
118752
- let prs = await this.findPrsForIssue(
118753
- issueId,
118754
- "open",
118755
- "number,headRefName,url,state,mergedAt"
118756
- );
118757
118817
  let prState = "open";
118758
- if (!prs.length) {
118759
- prs = await this.findPrsForIssue(issueId, "merged", "number,headRefName,url,state,mergedAt");
118760
- if (prs.length) prState = "merged";
118818
+ let prNumber;
118819
+ let prUrl;
118820
+ let sourceBranch;
118821
+ if (selector?.prNumber) {
118822
+ const status = await this.getPrStatusForNumber(selector.prNumber);
118823
+ if (!status?.number || !status.url || !status.sourceBranch) return null;
118824
+ prNumber = status.number;
118825
+ prUrl = status.url;
118826
+ sourceBranch = status.sourceBranch;
118827
+ prState = status.state === PrState.MERGED ? "merged" : status.state === PrState.CLOSED ? "closed" : "open";
118828
+ } else {
118829
+ let prs = await this.findPrsForIssue(
118830
+ issueId,
118831
+ "open",
118832
+ "number,headRefName,url,state,mergedAt"
118833
+ );
118834
+ if (!prs.length) {
118835
+ prs = await this.findPrsForIssue(issueId, "merged", "number,headRefName,url,state,mergedAt");
118836
+ if (prs.length) prState = "merged";
118837
+ }
118838
+ if (!prs.length) return null;
118839
+ const pr = prs[0];
118840
+ prNumber = pr.number;
118841
+ prUrl = pr.url ?? null;
118842
+ sourceBranch = pr.headRefName;
118761
118843
  }
118762
- if (!prs.length) return null;
118763
- const pr = prs[0];
118764
118844
  const raw = await this.gh([
118765
118845
  "api",
118766
- `repos/:owner/:repo/pulls/${pr.number}`,
118846
+ `repos/:owner/:repo/pulls/${prNumber}`,
118767
118847
  "--jq",
118768
118848
  "{headSha: .head.sha, repositoryId: .head.repo.id, owner: .head.repo.owner.login, repo: .head.repo.name}"
118769
118849
  ]);
118770
118850
  const extra = JSON.parse(raw);
118771
118851
  if (!extra.headSha || !extra.repositoryId || !extra.owner || !extra.repo) return null;
118772
118852
  return {
118773
- prNumber: pr.number,
118853
+ prNumber,
118774
118854
  headSha: extra.headSha,
118775
118855
  prState,
118776
- prUrl: pr.url ?? null,
118777
- sourceBranch: pr.headRefName,
118856
+ prUrl,
118857
+ sourceBranch,
118778
118858
  repositoryId: extra.repositoryId,
118779
118859
  owner: extra.owner,
118780
118860
  repo: extra.repo
@@ -119408,26 +119488,20 @@ function resolveRoute(ctx, explicitChannelId, explicitMessageThreadId) {
119408
119488
  accountId: ctx.agentAccountId
119409
119489
  };
119410
119490
  }
119411
- async function resolveProject(workspaceDir, channelId, opts) {
119491
+ async function resolveProjectFromContext(workspaceDir, ctx, explicitChannelId, explicitMessageThreadId) {
119492
+ const route = resolveRoute(ctx, explicitChannelId, explicitMessageThreadId);
119412
119493
  const data = await readProjects(workspaceDir);
119413
- const project = getProject(data, channelId, opts?.messageThreadId);
119494
+ const project = getProjectByRoute(data, route) ?? getProject(data, route.channelId, route.messageThreadId ?? void 0);
119414
119495
  if (!project) {
119415
119496
  const topicScopedCount = Object.values(data.projects).filter(
119416
- (candidate) => candidate.channels.some((channel) => String(channel.channelId) === String(channelId) && channel.messageThreadId !== void 0 && channel.messageThreadId !== null)
119497
+ (candidate) => candidate.channels.some((channel) => String(channel.channelId) === String(route.channelId) && channel.messageThreadId !== void 0 && channel.messageThreadId !== null)
119417
119498
  ).length;
119418
- const threadHint = topicScopedCount > 0 && opts?.messageThreadId == null ? " This Telegram forum group has topic-scoped projects; pass the messageThreadId from the current topic." : "";
119499
+ const threadHint = topicScopedCount > 0 && route.messageThreadId == null ? " This Telegram forum group has topic-scoped projects; pass the messageThreadId from the current topic." : "";
119419
119500
  throw new Error(
119420
- `No project found for "${opts?.messageThreadId ? routeKey({ channel: "telegram", channelId, messageThreadId: opts.messageThreadId }) : channelId}". Register a new project with project_register, or link this channel to an existing project.${threadHint}`
119501
+ `No project found for "${routeKey(route)}". Register a new project with project_register, or link this channel to an existing project.${threadHint}`
119421
119502
  );
119422
119503
  }
119423
- return { data, project };
119424
- }
119425
- async function resolveProjectFromContext(workspaceDir, ctx, explicitChannelId, explicitMessageThreadId) {
119426
- const route = resolveRoute(ctx, explicitChannelId, explicitMessageThreadId);
119427
- const resolved = await resolveProject(workspaceDir, route.channelId, {
119428
- messageThreadId: route.messageThreadId ?? void 0
119429
- });
119430
- return { ...resolved, route };
119504
+ return { data, project, route };
119431
119505
  }
119432
119506
  async function resolveProvider(project, runCommand, pluginConfig) {
119433
119507
  return createProvider({
@@ -119979,27 +120053,30 @@ async function notify(event, opts) {
119979
120053
  });
119980
120054
  return true;
119981
120055
  }
119982
- const notifyKey = computeNotifyKey(
119983
- event.project ?? "global",
119984
- event.issueId ?? 0,
119985
- event.type
119986
- );
119987
- const isNew = await writeIntent(opts.workspaceDir, notifyKey, event, {
119988
- channelId: target.channelId,
119989
- channel: target.channel,
119990
- accountId: target.accountId,
119991
- messageThreadId: target.messageThreadId
119992
- }).catch(() => false);
119993
- if (!isNew) {
119994
- await log(opts.workspaceDir, "notify_skip", {
119995
- eventId,
119996
- correlationId,
119997
- eventType: event.type,
119998
- reason: "duplicate outbox key",
119999
- key: notifyKey
120000
- }).catch(() => {
120001
- });
120002
- return true;
120056
+ let notifyKey = null;
120057
+ if (!opts.skipOutboxWrite) {
120058
+ notifyKey = computeNotifyKey(
120059
+ event.project ?? "global",
120060
+ event.issueId ?? 0,
120061
+ event.type
120062
+ );
120063
+ const isNew = await writeIntent(opts.workspaceDir, notifyKey, event, {
120064
+ channelId: target.channelId,
120065
+ channel: target.channel,
120066
+ accountId: target.accountId,
120067
+ messageThreadId: target.messageThreadId
120068
+ }).catch(() => false);
120069
+ if (!isNew) {
120070
+ await log(opts.workspaceDir, "notify_skip", {
120071
+ eventId,
120072
+ correlationId,
120073
+ eventType: event.type,
120074
+ reason: "duplicate outbox key",
120075
+ key: notifyKey
120076
+ }).catch(() => {
120077
+ });
120078
+ return true;
120079
+ }
120003
120080
  }
120004
120081
  await log(opts.workspaceDir, "notify", {
120005
120082
  eventId,
@@ -120030,7 +120107,7 @@ async function notify(event, opts) {
120030
120107
  target
120031
120108
  }
120032
120109
  );
120033
- if (sent) {
120110
+ if (sent && notifyKey) {
120034
120111
  await markDelivered(opts.workspaceDir, notifyKey).catch(() => {
120035
120112
  });
120036
120113
  }
@@ -120864,8 +120941,12 @@ Once the PR shows as mergeable on GitHub, call work_finish again.`
120864
120941
  if (err instanceof Error && (err.message.startsWith("Cannot mark work_finish(done)") || err.message.startsWith("Cannot complete work_finish(done)"))) {
120865
120942
  throw err;
120866
120943
  }
120867
- logger6.warn({ err }, "PR validation warning; failing open");
120868
- return { state: PrState.CLOSED, url: null };
120944
+ logger6.warn({ err }, "PR validation warning; failing closed");
120945
+ throw new Error(
120946
+ `Cannot mark work_finish(done) because Fabrica could not verify PR state right now.
120947
+
120948
+ Resolve the provider/API error and call work_finish again.`
120949
+ );
120869
120950
  }
120870
120951
  }
120871
120952
  function shouldAutoRecoverToFeedback(summary) {
@@ -120873,58 +120954,19 @@ function shouldAutoRecoverToFeedback(summary) {
120873
120954
  const text = summary.toLowerCase();
120874
120955
  return /retarget/.test(text) || /mismatch de escopo/.test(text) || /mismatch de escopo\/rastreabilidade/.test(text) || /new pr/.test(text) || /novo pr/.test(text) || /não pode satisfazer a issue/.test(text) || /cannot satisfy issue/.test(text);
120875
120956
  }
120876
- async function validateReviewerArtifact(provider, issueId, expectedResult, artifactId, artifactType, selector) {
120877
- if (!artifactId || !artifactType) {
120878
- throw new Error(
120879
- "Reviewer must publish feedback to the PR with review_submit before calling work_finish."
120880
- );
120881
- }
120882
- const comments = await provider.getPrReviewComments(issueId, selector);
120883
- const match = comments.find(
120884
- (comment) => matchesReviewArtifact(comment, artifactId, artifactType)
120885
- );
120886
- if (!match) {
120887
- throw new Error(
120888
- `Review artifact #${artifactId} (${artifactType}) was not found on the PR for issue #${issueId}.`
120889
- );
120890
- }
120891
- if (artifactType === "formal_review") {
120892
- if (expectedResult === "approve" && match.state !== "APPROVED") {
120893
- throw new Error(
120894
- `Review artifact #${artifactId} must be an APPROVED review before reviewer approve can complete issue #${issueId}.`
120895
- );
120896
- }
120897
- if (expectedResult === "reject" && match.state !== "CHANGES_REQUESTED") {
120898
- throw new Error(
120899
- `Review artifact #${artifactId} must be a CHANGES_REQUESTED review before reviewer reject can complete issue #${issueId}.`
120900
- );
120901
- }
120902
- }
120903
- }
120904
- async function getCanonicalQaEvidenceValidationForPr(provider, issueId, selector) {
120905
- const prStatus = await provider.getPrStatus(issueId, selector);
120906
- return validateCanonicalQaEvidence(prStatus.body);
120907
- }
120908
- function matchesReviewArtifact(comment, artifactId, artifactType) {
120909
- if (comment.id !== artifactId) return false;
120910
- if (artifactType === "formal_review") {
120911
- return comment.state === "APPROVED" || comment.state === "CHANGES_REQUESTED";
120912
- }
120913
- return comment.state === "COMMENTED" && !comment.path;
120914
- }
120915
120957
  var INFRA_FAIL_CIRCUIT_BREAKER_THRESHOLD = 2;
120916
120958
  function createWorkFinishTool(ctx) {
120917
120959
  return (toolCtx) => ({
120918
120960
  name: "work_finish",
120919
120961
  label: "Work Finish",
120920
- description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
120962
+ description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/fail_infra/refine/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
120921
120963
  parameters: {
120922
120964
  type: "object",
120923
120965
  required: ["channelId", "role", "result"],
120924
120966
  properties: {
120925
- channelId: { type: "string", description: "Project slug (e.g. 'my-project'). Use the value from the 'Channel:' or 'Project:' line in your task message. Do NOT use a numeric Telegram chat ID." },
120967
+ channelId: { type: "string", description: "Project slug (e.g. 'my-project'). Use the value from the 'Channel:' line in your task message. Do NOT use a numeric Telegram chat ID." },
120926
120968
  role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
120927
- result: { type: "string", enum: ["done", "pass", "fail", "fail_infra", "refine", "blocked", "approve", "reject"], description: "Completion result. Use fail_infra (tester only) when the test toolchain is missing or broken \u2014 this keeps the issue in the test queue instead of routing it to the developer." },
120969
+ result: { type: "string", enum: ["done", "pass", "fail", "fail_infra", "refine", "blocked"], description: "Completion result. Use fail_infra (tester only) when the test toolchain is missing or broken \u2014 this keeps the issue in the test queue instead of routing it to the developer." },
120928
120970
  summary: { type: "string", description: "Brief summary" },
120929
120971
  prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
120930
120972
  createdTasks: {
@@ -120939,15 +120981,6 @@ function createWorkFinishTool(ctx) {
120939
120981
  }
120940
120982
  },
120941
120983
  description: "Tasks created during this work session (architect creates implementation tasks)."
120942
- },
120943
- reviewArtifactId: {
120944
- type: "number",
120945
- description: "Artifact ID returned by review_submit. Required for reviewer approve/reject."
120946
- },
120947
- reviewArtifactType: {
120948
- type: "string",
120949
- enum: ["formal_review", "pr_conversation_comment"],
120950
- description: "Artifact type returned by review_submit. Required for reviewer approve/reject."
120951
120984
  }
120952
120985
  }
120953
120986
  },
@@ -120957,16 +120990,13 @@ function createWorkFinishTool(ctx) {
120957
120990
  const summary = params.summary;
120958
120991
  const prUrl = params.prUrl;
120959
120992
  const createdTasks = params.createdTasks;
120960
- const reviewArtifactId = params.reviewArtifactId;
120961
- const reviewArtifactType = params.reviewArtifactType;
120993
+ const dispatchRunId = typeof params._dispatchRunId === "string" ? params._dispatchRunId : void 0;
120962
120994
  const workspaceDir = requireWorkspaceDir(toolCtx);
120963
- await recordIssueLifecycleBySessionKey({
120964
- workspaceDir,
120965
- sessionKey: toolCtx.sessionKey,
120966
- stage: "first_worker_activity",
120967
- details: { source: "work_finish" }
120968
- }).catch(() => {
120969
- });
120995
+ if (role === "reviewer") {
120996
+ throw new Error(
120997
+ "Reviewer completion is no longer handled by work_finish. End your response with exactly one plain-text decision line: 'Review result: APPROVE' or 'Review result: REJECT'. Use the project slug from the 'Channel:' line in your task message for any follow-up task_create call."
120998
+ );
120999
+ }
120970
121000
  if (!isValidResult(role, result)) {
120971
121001
  const valid = getCompletionResults(role);
120972
121002
  throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`);
@@ -120979,6 +121009,41 @@ function createWorkFinishTool(ctx) {
120979
121009
  }
120980
121010
  const { slotIndex, slotLevel, issueId, recovered } = workerSlot;
120981
121011
  const issueRuntime = project.issueRuntime?.[String(issueId)];
121012
+ const currentDispatchRunId = workerSlot.dispatchRunId ?? issueRuntime?.dispatchRunId ?? null;
121013
+ const hasCycleMismatch = Boolean(
121014
+ workerSlot.dispatchCycleId && issueRuntime?.lastDispatchCycleId && workerSlot.dispatchCycleId !== issueRuntime.lastDispatchCycleId
121015
+ );
121016
+ if (!dispatchRunId || !currentDispatchRunId || dispatchRunId !== currentDispatchRunId || hasCycleMismatch) {
121017
+ await log(workspaceDir, "work_finish_rejected", {
121018
+ project: project.name,
121019
+ projectSlug: project.slug,
121020
+ issue: issueId,
121021
+ role,
121022
+ result,
121023
+ reason: "stale_dispatch_cycle",
121024
+ sessionKey: toolCtx.sessionKey ?? null,
121025
+ providedDispatchRunId: dispatchRunId ?? null,
121026
+ currentDispatchRunId,
121027
+ slotDispatchCycleId: workerSlot.dispatchCycleId ?? null,
121028
+ runtimeDispatchCycleId: issueRuntime?.lastDispatchCycleId ?? null
121029
+ });
121030
+ return jsonResult2({
121031
+ success: false,
121032
+ project: project.name,
121033
+ projectSlug: project.slug,
121034
+ issueId,
121035
+ role,
121036
+ result,
121037
+ reason: "stale_dispatch_cycle"
121038
+ });
121039
+ }
121040
+ await recordIssueLifecycleBySessionKey({
121041
+ workspaceDir,
121042
+ sessionKey: toolCtx.sessionKey,
121043
+ stage: "first_worker_activity",
121044
+ details: { source: "work_finish" }
121045
+ }).catch(() => {
121046
+ });
120982
121047
  const prSelector = role === "reviewer" || role === "tester" ? requireCanonicalPrSelector(project, issueId, `${role} completion`) : issueRuntime?.currentPrNumber ? { prNumber: issueRuntime.currentPrNumber } : void 0;
120983
121048
  if (recovered) {
120984
121049
  await log(workspaceDir, "work_finish_recovered_slot", {
@@ -121118,24 +121183,6 @@ function createWorkFinishTool(ctx) {
121118
121183
  boundAt: (/* @__PURE__ */ new Date()).toISOString()
121119
121184
  });
121120
121185
  }
121121
- if (role === "reviewer" && (result === "approve" || result === "reject")) {
121122
- await validateReviewerArtifact(provider, issueId, result, reviewArtifactId, reviewArtifactType, prSelector);
121123
- }
121124
- if (role === "reviewer" && result === "approve") {
121125
- const qaEvidence = await getCanonicalQaEvidenceValidationForPr(provider, issueId, prSelector);
121126
- if (!qaEvidence.valid) {
121127
- await log(workspaceDir, "work_finish_rejected", {
121128
- project: project.slug,
121129
- issue: issueId,
121130
- reason: "invalid_qa_evidence",
121131
- role,
121132
- result,
121133
- prNumber: issueRuntime?.currentPrNumber ?? null,
121134
- qaProblems: qaEvidence.problems
121135
- });
121136
- throwInvalidQaEvidence(qaEvidence, "reviewer");
121137
- }
121138
- }
121139
121186
  const completion = await ctx.observability.withContext({
121140
121187
  sessionKey: toolCtx.sessionKey ?? void 0,
121141
121188
  issueId,
@@ -121230,7 +121277,9 @@ function resolveWorkerSlot(roleWorker, sessionKey) {
121230
121277
  slotIndex: i2,
121231
121278
  slotLevel: level,
121232
121279
  issueId: Number(slot.issueId),
121233
- recovered: false
121280
+ recovered: false,
121281
+ dispatchCycleId: slot.dispatchCycleId ?? null,
121282
+ dispatchRunId: slot.dispatchRunId ?? null
121234
121283
  };
121235
121284
  }
121236
121285
  }
@@ -121244,7 +121293,9 @@ function resolveWorkerSlot(roleWorker, sessionKey) {
121244
121293
  slotIndex: i2,
121245
121294
  slotLevel: level,
121246
121295
  issueId: Number(slot.lastIssueId),
121247
- recovered: true
121296
+ recovered: true,
121297
+ dispatchCycleId: slot.dispatchCycleId ?? null,
121298
+ dispatchRunId: slot.dispatchRunId ?? null
121248
121299
  };
121249
121300
  }
121250
121301
  }
@@ -121505,7 +121556,7 @@ Use cases:
121505
121556
  - Orchestrator adds summary comments
121506
121557
  - Cross-referencing related issues or PRs
121507
121558
 
121508
- Blocking reviewer decisions belong in \`review_submit\`, not \`task_comment\`.
121559
+ Reviewer worker decisions belong in the review response, not in \`task_comment\`.
121509
121560
 
121510
121561
  Examples:
121511
121562
  - Simple: { issueId: 42, body: "Found an edge case with null inputs" }
@@ -121564,7 +121615,7 @@ Examples:
121564
121615
  sessionKey: toolCtx.sessionKey
121565
121616
  });
121566
121617
  throw new Error(
121567
- "Reviewer findings must be published to the PR with review_submit. task_comment is reserved for issue-side operational notes from non-reviewer roles."
121618
+ "Reviewer workers must keep findings in the review response and finish with `Review result: APPROVE` or `Review result: REJECT`. task_comment is reserved for issue-side operational notes from non-reviewer roles."
121568
121619
  );
121569
121620
  }
121570
121621
  const { provider, type: providerType } = await resolveProvider(project, ctx.runCommand);
@@ -121576,7 +121627,7 @@ Examples:
121576
121627
  sessionKey: toolCtx.sessionKey ?? null
121577
121628
  });
121578
121629
  throw new Error(
121579
- "Reviewer feedback must be published on the PR with review_submit. task_comment is not allowed for reviewer findings."
121630
+ "Reviewer worker findings must stay in the review response. task_comment is not allowed for reviewer findings."
121580
121631
  );
121581
121632
  }
121582
121633
  const issue2 = await provider.getIssue(issueId);
@@ -122193,6 +122244,7 @@ import { jsonResult as jsonResult9 } from "openclaw/plugin-sdk";
122193
122244
 
122194
122245
  // lib/dispatch/index.ts
122195
122246
  init_audit();
122247
+ import { randomUUID as randomUUID2 } from "node:crypto";
122196
122248
  init_roles();
122197
122249
  init_workflow();
122198
122250
 
@@ -122626,6 +122678,7 @@ function buildTaskMessage(opts) {
122626
122678
  const results = opts.resolvedRole?.completionResults ?? [];
122627
122679
  const availableResults = results.map((r2) => `"${r2}"`).join(", ");
122628
122680
  const isFeedbackCycle = !!opts.prFeedback;
122681
+ const requiresWorkFinish = role !== "reviewer";
122629
122682
  const parts = [
122630
122683
  `${role.toUpperCase()} task for project "${projectName}" \u2014 Issue #${issueId}`,
122631
122684
  ``,
@@ -122690,23 +122743,25 @@ ${issueDescription}` : ""
122690
122743
  `Repo: ${repoDisplay} | Branch: ${baseBranch} | ${issueUrl}`,
122691
122744
  `Project: ${projectName} | Channel: ${channelId}`
122692
122745
  );
122693
- parts.push(
122694
- ``,
122695
- `---`,
122696
- ``,
122697
- `## MANDATORY: Task Completion`,
122698
- ``,
122699
- `When you finish this task, you MUST invoke the \`work_finish\` **tool** (API tool_use call \u2014 NOT a shell command):`,
122700
- `- \`role\`: "${role}"`,
122701
- `- \`channelId\`: "${channelId}"`,
122702
- `- \`result\`: ${availableResults}`,
122703
- `- \`summary\`: brief description of what you did`,
122704
- ``,
122705
- `\u26A0\uFE0F \`work_finish\` is a Fabrica tool, not a CLI command. Call it as a tool (the same way you use task_create or other tools), not via bash.`,
122706
- `\u26A0\uFE0F You MUST call work_finish even if you encounter errors or cannot finish.`,
122707
- `Use "blocked" with a summary explaining why you're stuck.`,
122708
- `Never end your session without calling work_finish.`
122709
- );
122746
+ if (requiresWorkFinish) {
122747
+ parts.push(
122748
+ ``,
122749
+ `---`,
122750
+ ``,
122751
+ `## MANDATORY: Task Completion`,
122752
+ ``,
122753
+ `When you finish this task, you MUST invoke the \`work_finish\` **tool** (API tool_use call \u2014 NOT a shell command):`,
122754
+ `- \`role\`: "${role}"`,
122755
+ `- \`channelId\`: "${channelId}" (project slug from the \`Channel:\` line above)`,
122756
+ `- \`result\`: ${availableResults}`,
122757
+ `- \`summary\`: brief description of what you did`,
122758
+ ``,
122759
+ `\u26A0\uFE0F \`work_finish\` is a Fabrica tool, not a CLI command. Call it as a tool (the same way you use task_create or other tools), not via bash.`,
122760
+ `\u26A0\uFE0F You MUST call work_finish even if you encounter errors or cannot finish.`,
122761
+ `Use "blocked" with a summary explaining why you're stuck.`,
122762
+ `Never end your session without calling work_finish.`
122763
+ );
122764
+ }
122710
122765
  return parts.join("\n");
122711
122766
  }
122712
122767
  function buildConflictFixMessage(opts) {
@@ -122725,6 +122780,7 @@ function buildConflictFixMessage(opts) {
122725
122780
  const repoDisplay = sanitizeRepoContext(repo);
122726
122781
  const results = opts.resolvedRole?.completionResults ?? [];
122727
122782
  const availableResults = results.map((r2) => `"${r2}"`).join(", ");
122783
+ const requiresWorkFinish = role !== "reviewer";
122728
122784
  const parts = [
122729
122785
  `${role.toUpperCase()} task for project "${projectName}" \u2014 Issue #${issueId}`,
122730
122786
  ``,
@@ -122738,23 +122794,25 @@ function buildConflictFixMessage(opts) {
122738
122794
  `Repo: ${repoDisplay} | Branch: ${baseBranch} | ${issueUrl}`,
122739
122795
  `Project: ${projectName} | Channel: ${channelId}`
122740
122796
  );
122741
- parts.push(
122742
- ``,
122743
- `---`,
122744
- ``,
122745
- `## MANDATORY: Task Completion`,
122746
- ``,
122747
- `When you finish this task, you MUST invoke the \`work_finish\` **tool** (API tool_use call \u2014 NOT a shell command):`,
122748
- `- \`role\`: "${role}"`,
122749
- `- \`channelId\`: "${channelId}"`,
122750
- `- \`result\`: ${availableResults}`,
122751
- `- \`summary\`: brief description of what you did`,
122752
- ``,
122753
- `\u26A0\uFE0F \`work_finish\` is a Fabrica tool, not a CLI command. Call it as a tool (the same way you use task_create or other tools), not via bash.`,
122754
- `\u26A0\uFE0F You MUST call work_finish even if you encounter errors or cannot finish.`,
122755
- `Use "blocked" with a summary explaining why you're stuck.`,
122756
- `Never end your session without calling work_finish.`
122757
- );
122797
+ if (requiresWorkFinish) {
122798
+ parts.push(
122799
+ ``,
122800
+ `---`,
122801
+ ``,
122802
+ `## MANDATORY: Task Completion`,
122803
+ ``,
122804
+ `When you finish this task, you MUST invoke the \`work_finish\` **tool** (API tool_use call \u2014 NOT a shell command):`,
122805
+ `- \`role\`: "${role}"`,
122806
+ `- \`channelId\`: "${channelId}" (project slug from the \`Channel:\` line above)`,
122807
+ `- \`result\`: ${availableResults}`,
122808
+ `- \`summary\`: brief description of what you did`,
122809
+ ``,
122810
+ `\u26A0\uFE0F \`work_finish\` is a Fabrica tool, not a CLI command. Call it as a tool (the same way you use task_create or other tools), not via bash.`,
122811
+ `\u26A0\uFE0F You MUST call work_finish even if you encounter errors or cannot finish.`,
122812
+ `Use "blocked" with a summary explaining why you're stuck.`,
122813
+ `Never end your session without calling work_finish.`
122814
+ );
122815
+ }
122758
122816
  return parts.join("\n");
122759
122817
  }
122760
122818
  function buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl, resolvedRole, botName) {
@@ -123006,6 +123064,10 @@ function sendToAgent(sessionKey, taskMessage, opts) {
123006
123064
  deliver: false,
123007
123065
  ...opts.extraSystemPrompt ? { extraSystemPrompt: opts.extraSystemPrompt } : {}
123008
123066
  }).then((result) => {
123067
+ if (result?.runId) {
123068
+ bindDispatchRunIdBySessionKey(opts.workspaceDir, sessionKey, result.runId).catch(() => {
123069
+ });
123070
+ }
123009
123071
  log(opts.workspaceDir, "dispatch_agent_sent", {
123010
123072
  step: "sendToAgent",
123011
123073
  sessionKey,
@@ -123229,6 +123291,7 @@ async function dispatchTask(opts) {
123229
123291
  }
123230
123292
  }
123231
123293
  const sessionAction = existingSessionKey ? "send" : "spawn";
123294
+ const dispatchCycleId = randomUUID2();
123232
123295
  const allComments = await provider.listComments(issueId);
123233
123296
  const { workflow } = resolvedConfig;
123234
123297
  const prFeedback = isFeedbackState(workflow, fromLabel) ? await fetchPrFeedback(provider, issueId, prSelector) : void 0;
@@ -123294,12 +123357,16 @@ async function dispatchTask(opts) {
123294
123357
  sessionKey,
123295
123358
  sessionAction,
123296
123359
  fromLabel,
123297
- name: botName
123360
+ name: botName,
123361
+ dispatchCycleId
123298
123362
  });
123299
123363
  await updateIssueRuntime(workspaceDir, project.slug, String(issueId), {
123300
123364
  dispatchRequestedAt: (/* @__PURE__ */ new Date()).toISOString(),
123365
+ lastDispatchCycleId: dispatchCycleId,
123366
+ dispatchRunId: null,
123301
123367
  agentAcceptedAt: null,
123302
- firstWorkerActivityAt: null
123368
+ firstWorkerActivityAt: null,
123369
+ lastSessionKey: sessionKey
123303
123370
  }).catch((err) => {
123304
123371
  log(workspaceDir, "dispatch_warning", { step: "record_dispatch_requested", issue: issueId, err: String(err) }).catch(() => {
123305
123372
  });
@@ -123485,6 +123552,8 @@ async function recordWorkerState(workspaceDir, slug, role, slotIndex, opts) {
123485
123552
  level: opts.level,
123486
123553
  sessionKey: opts.sessionKey,
123487
123554
  startTime: (/* @__PURE__ */ new Date()).toISOString(),
123555
+ dispatchCycleId: opts.dispatchCycleId,
123556
+ dispatchRunId: null,
123488
123557
  previousLabel: opts.fromLabel,
123489
123558
  slotIndex,
123490
123559
  name: opts.name
@@ -123810,7 +123879,6 @@ function createReviewSubmitTool(ctx) {
123810
123879
  label: "Review Submit",
123811
123880
  description: `Submit canonical review feedback to the PR linked to an issue.
123812
123881
 
123813
- Use this instead of task_comment for reviewer approvals/rejections.
123814
123882
  It writes the review to the PR itself, preferring a formal PR review and
123815
123883
  falling back to a top-level PR conversation comment when necessary.`,
123816
123884
  parameters: {
@@ -123841,6 +123909,20 @@ falling back to a top-level PR conversation comment when necessary.`,
123841
123909
  const result = params.result;
123842
123910
  const body = params.body;
123843
123911
  const workspaceDir = requireWorkspaceDir(toolCtx);
123912
+ const workerSession = toolCtx.sessionKey ? parseFabricaSessionKey(toolCtx.sessionKey) : null;
123913
+ if (workerSession) {
123914
+ await log(workspaceDir, "review_submit_blocked", {
123915
+ project: workerSession.projectName,
123916
+ role: workerSession.role,
123917
+ issue: issueId,
123918
+ sessionKey: toolCtx.sessionKey,
123919
+ reason: "fabrica_worker_session"
123920
+ }).catch(() => {
123921
+ });
123922
+ throw new Error(
123923
+ workerSession.role === "reviewer" ? "Reviewer workers must finish by ending their response with `Review result: APPROVE` or `Review result: REJECT`. Do not call review_submit." : "Fabrica worker sessions must not call review_submit. Use the role's normal completion contract instead."
123924
+ );
123925
+ }
123844
123926
  await recordIssueLifecycleBySessionKey({
123845
123927
  workspaceDir,
123846
123928
  sessionKey: toolCtx.sessionKey,
@@ -124206,12 +124288,7 @@ async function createProjectForumTopic(ctx, opts) {
124206
124288
  }
124207
124289
  }
124208
124290
  }
124209
- return {
124210
- chatId: opts.chatId,
124211
- topicId: 1,
124212
- name: opts.name,
124213
- isFallback: true
124214
- };
124291
+ throw lastError ?? new Error("Telegram topic creation failed after retry exhaustion");
124215
124292
  }
124216
124293
 
124217
124294
  // lib/telegram/config.ts
@@ -124233,6 +124310,11 @@ function readFabricaTelegramConfig(pluginConfig) {
124233
124310
  };
124234
124311
  }
124235
124312
 
124313
+ // lib/intake/lib/artifact-ids.ts
124314
+ function buildForumTopicArtifactId(channelId, messageThreadId) {
124315
+ return `telegram:${channelId}:${messageThreadId}`;
124316
+ }
124317
+
124236
124318
  // lib/tools/admin/project-register.ts
124237
124319
  async function scaffoldPromptFiles(workspaceDir, projectName) {
124238
124320
  const projectDir = path22.join(workspaceDir, DATA_DIR, "projects", projectName);
@@ -124311,6 +124393,11 @@ async function hasProjectWorkflowOverride(workspaceDir, projectName) {
124311
124393
  return false;
124312
124394
  }
124313
124395
  }
124396
+ function normalizeRepoIdentity(value) {
124397
+ const trimmed = value?.trim();
124398
+ if (!trimmed) return null;
124399
+ return trimmed.replace(/\.git$/i, "").toLowerCase();
124400
+ }
124314
124401
  function adaptStepRunCommand(runCommand) {
124315
124402
  return async (argv, optionsOrTimeout) => {
124316
124403
  const [cmd, ...args] = argv;
@@ -124352,6 +124439,7 @@ async function registerProject(params) {
124352
124439
  messageThreadId: route.messageThreadId ?? void 0,
124353
124440
  accountId: route.accountId
124354
124441
  });
124442
+ let createdArtifacts = [];
124355
124443
  await acquireLock(workspaceDir);
124356
124444
  try {
124357
124445
  const data = await readProjects(workspaceDir);
@@ -124387,6 +124475,21 @@ async function registerProject(params) {
124387
124475
  `${providerType.toUpperCase()} health check failed for ${repoPath}. Detected provider: ${providerType}. Ensure '${cliName}' CLI is installed, authenticated (${cliName} auth status), and the repo has a ${providerType.toUpperCase()} remote. Install ${cliName} from: ${cliInstallUrl}`
124388
124476
  );
124389
124477
  }
124478
+ let repoRemote;
124479
+ try {
124480
+ repoRemote = await provider.resolveRepositoryRemote() ?? void 0;
124481
+ } catch {
124482
+ repoRemote = void 0;
124483
+ }
124484
+ if (existing) {
124485
+ const sameRepoPath = resolveRepoPath(existing.repo) === repoPath;
124486
+ const sameRepoRemote = normalizeRepoIdentity(existing.repoRemote) != null && normalizeRepoIdentity(existing.repoRemote) === normalizeRepoIdentity(repoRemote);
124487
+ if (!sameRepoPath && !sameRepoRemote) {
124488
+ throw new Error(
124489
+ `Project slug "${slug}" already points to a different repository. Existing repo="${existing.repo}" remote="${existing.repoRemote ?? "unknown"}"; incoming repo="${repoPath}" remote="${repoRemote ?? "unknown"}".`
124490
+ );
124491
+ }
124492
+ }
124390
124493
  await provider.ensureAllStateLabels();
124391
124494
  const workflowOverrideCreated = autonomousProject ? await ensureAutonomousWorkflowOverride(
124392
124495
  workspaceDir,
@@ -124410,12 +124513,6 @@ async function registerProject(params) {
124410
124513
  for (const { name: labelName, color } of OPERATIONAL_LABELS) {
124411
124514
  await provider.ensureLabel(labelName, color);
124412
124515
  }
124413
- let repoRemote;
124414
- try {
124415
- repoRemote = await provider.resolveRepositoryRemote() ?? void 0;
124416
- } catch {
124417
- repoRemote = void 0;
124418
- }
124419
124516
  if (createProjectTopic) {
124420
124517
  if (!runtime || !config2) {
124421
124518
  throw new Error("Runtime and config are required to create a Telegram project topic");
@@ -124436,6 +124533,13 @@ async function registerProject(params) {
124436
124533
  name,
124437
124534
  accountId: telegramConfig.projectsForumAccountId ?? initialRoute.accountId ?? void 0
124438
124535
  });
124536
+ if (createdTopic.isFallback || createdTopic.topicId === 1) {
124537
+ throw new Error("DM bootstrap requires a dedicated Telegram topic; refusing to register against the General topic");
124538
+ }
124539
+ createdArtifacts = [{
124540
+ type: "forum_topic",
124541
+ id: buildForumTopicArtifactId(createdTopic.chatId, createdTopic.topicId)
124542
+ }];
124439
124543
  targetRoute = buildRouteRef({
124440
124544
  channel: "telegram",
124441
124545
  channelId: createdTopic.chatId,
@@ -124524,10 +124628,33 @@ async function registerProject(params) {
124524
124628
  },
124525
124629
  announcement: `${action}. Labels ensured.${promptsNote} Ready for tasks.`
124526
124630
  };
124631
+ } catch (err) {
124632
+ if (createdArtifacts.length > 0) {
124633
+ throw attachArtifactsToError(err, createdArtifacts);
124634
+ }
124635
+ throw err;
124527
124636
  } finally {
124528
124637
  await releaseLock(workspaceDir);
124529
124638
  }
124530
124639
  }
124640
+ function attachArtifactsToError(error48, artifacts) {
124641
+ const err = error48 instanceof Error ? error48 : new Error(String(error48));
124642
+ const existing = Array.isArray(err.artifacts) ? err.artifacts : [];
124643
+ const merged = [...existing, ...artifacts].filter((artifact, index, array2) => {
124644
+ if (!artifact || typeof artifact !== "object" || typeof artifact.type !== "string" || typeof artifact.id !== "string") {
124645
+ return false;
124646
+ }
124647
+ const current = `${artifact.type}:${artifact.id}`;
124648
+ return index === array2.findIndex((candidate) => {
124649
+ if (!candidate || typeof candidate !== "object" || typeof candidate.type !== "string" || typeof candidate.id !== "string") {
124650
+ return false;
124651
+ }
124652
+ return current === `${candidate.type}:${candidate.id}`;
124653
+ });
124654
+ });
124655
+ err.artifacts = merged;
124656
+ return err;
124657
+ }
124531
124658
  function createProjectRegisterTool(ctx) {
124532
124659
  return (toolCtx) => ({
124533
124660
  name: "project_register",
@@ -124652,6 +124779,11 @@ async function auditHealthFixApplied(workspaceDir, fix, details) {
124652
124779
  });
124653
124780
  }));
124654
124781
  }
124782
+ function hasDispatchCycleMismatch(slot, issueRuntime) {
124783
+ return Boolean(
124784
+ slot.dispatchCycleId && issueRuntime?.lastDispatchCycleId && slot.dispatchCycleId !== issueRuntime.lastDispatchCycleId
124785
+ );
124786
+ }
124655
124787
  async function fetchIssue(provider, issueId) {
124656
124788
  try {
124657
124789
  return await provider.getIssue(issueId);
@@ -124662,17 +124794,16 @@ async function fetchIssue(provider, issueId) {
124662
124794
  function isIssueClosed(issue2) {
124663
124795
  return issue2.state.toLowerCase() === "closed";
124664
124796
  }
124665
- async function resolveOrphanRevertLabel(provider, issueId, role, defaultQueueLabel, workflow) {
124797
+ async function resolveOrphanRevertLabel(provider, project, issueId, role, defaultQueueLabel, workflow) {
124666
124798
  try {
124667
- const prStatus = await provider.getPrStatus(issueId);
124799
+ const prSelector = getCanonicalPrSelector(project, issueId);
124800
+ const prStatus = await provider.getPrStatus(issueId, prSelector);
124668
124801
  if (prStatus.url && prStatus.state !== PrState.MERGED && prStatus.state !== PrState.CLOSED && prStatus.currentIssueMatch !== false) {
124669
124802
  if (prStatus.state === PrState.CHANGES_REQUESTED || prStatus.state === PrState.HAS_COMMENTS) {
124670
124803
  const queueLabels = getQueueLabels(workflow, role);
124671
124804
  const feedbackLabel = queueLabels.find((l) => isFeedbackState(workflow, l));
124672
124805
  if (feedbackLabel) return feedbackLabel;
124673
124806
  }
124674
- const rule = getCompletionRule(workflow, role, "done");
124675
- if (rule) return rule.to;
124676
124807
  }
124677
124808
  } catch {
124678
124809
  }
@@ -124692,7 +124823,8 @@ async function checkWorkerHealth(opts) {
124692
124823
  sessions,
124693
124824
  workflow = DEFAULT_WORKFLOW,
124694
124825
  staleWorkerHours = 2,
124695
- dispatchConfirmTimeoutMs = DISPATCH_CONFIRMATION_TIMEOUT_MS
124826
+ dispatchConfirmTimeoutMs = DISPATCH_CONFIRMATION_TIMEOUT_MS,
124827
+ healthGracePeriodMs = GRACE_PERIOD_MS
124696
124828
  } = opts;
124697
124829
  const fixes = [];
124698
124830
  if (!hasWorkflowStates(workflow, role)) return fixes;
@@ -124705,7 +124837,7 @@ async function checkWorkerHealth(opts) {
124705
124837
  const sessionKey = slot.sessionKey;
124706
124838
  const slotQueueLabel = slot.previousLabel ?? queueLabel;
124707
124839
  const workerStartTime = slot.startTime ? new Date(slot.startTime).getTime() : null;
124708
- const withinGracePeriod = workerStartTime !== null && Date.now() - workerStartTime < GRACE_PERIOD_MS;
124840
+ const withinGracePeriod = workerStartTime !== null && Date.now() - workerStartTime < healthGracePeriodMs;
124709
124841
  const issueIdNum = slot.issueId ? Number(slot.issueId) : null;
124710
124842
  let issue2 = null;
124711
124843
  let currentLabel = null;
@@ -124733,6 +124865,23 @@ async function checkWorkerHealth(opts) {
124733
124865
  issueId: slot.issueId ?? void 0
124734
124866
  });
124735
124867
  }
124868
+ if (slot.active && hasDispatchCycleMismatch(slot, issueRuntime)) {
124869
+ await log(workspaceDir, "health_fix_rejected", {
124870
+ type: "dispatch_cycle_mismatch",
124871
+ reason: "stale_dispatch_cycle",
124872
+ project: project.name,
124873
+ projectSlug,
124874
+ role,
124875
+ level,
124876
+ slotIndex,
124877
+ issueId: slot.issueId ?? null,
124878
+ sessionKey,
124879
+ slotDispatchCycleId: slot.dispatchCycleId ?? null,
124880
+ runtimeDispatchCycleId: issueRuntime?.lastDispatchCycleId ?? null
124881
+ }).catch(() => {
124882
+ });
124883
+ continue;
124884
+ }
124736
124885
  if (slot.active && issueIdNum && !issue2) {
124737
124886
  const fix = {
124738
124887
  issue: {
@@ -125013,32 +125162,6 @@ async function checkWorkerHealth(opts) {
125013
125162
  fixes.push(fix);
125014
125163
  }
125015
125164
  }
125016
- if (slot.active && issueIdNum && issue2 && currentLabel === expectedLabel && autoFix) {
125017
- try {
125018
- const prStatus = await provider.getPrStatus(issueIdNum);
125019
- if (prStatus.url && prStatus.state !== PrState.MERGED && prStatus.state !== PrState.CLOSED && prStatus.state !== PrState.CHANGES_REQUESTED && prStatus.state !== PrState.HAS_COMMENTS && prStatus.currentIssueMatch !== false) {
125020
- const rule = getCompletionRule(workflow, role, "done");
125021
- if (rule && rule.to !== expectedLabel) {
125022
- await resilientLabelTransition(provider, issueIdNum, expectedLabel, rule.to);
125023
- await deactivateSlot();
125024
- await log(workspaceDir, "health_transition_to_review", {
125025
- project: project.name,
125026
- projectSlug,
125027
- role,
125028
- level,
125029
- issueId: slot.issueId,
125030
- sessionKey,
125031
- slotIndex,
125032
- fromLabel: expectedLabel,
125033
- toLabel: rule.to,
125034
- prUrl: prStatus.url
125035
- }).catch(() => {
125036
- });
125037
- }
125038
- }
125039
- } catch {
125040
- }
125041
- }
125042
125165
  if (!slot.active && issue2 && currentLabel === expectedLabel) {
125043
125166
  const fix = {
125044
125167
  issue: {
@@ -125188,6 +125311,7 @@ async function scanOrphanedLabels(opts) {
125188
125311
  try {
125189
125312
  const revertTarget = await resolveOrphanRevertLabel(
125190
125313
  provider,
125314
+ freshProject,
125191
125315
  issue2.iid,
125192
125316
  role,
125193
125317
  queueLabel,
@@ -125309,6 +125433,7 @@ function createHealthTool(ctx) {
125309
125433
  for (const slug of slugs) {
125310
125434
  const project = data.projects[slug];
125311
125435
  if (!project) continue;
125436
+ const resolvedConfig = await loadConfig(workspaceDir, slug);
125312
125437
  const { provider } = await resolveProvider(project, ctx.runCommand);
125313
125438
  for (const role of Object.keys(project.workers)) {
125314
125439
  const healthFixes = await checkWorkerHealth({
@@ -125318,7 +125443,10 @@ function createHealthTool(ctx) {
125318
125443
  role,
125319
125444
  sessions,
125320
125445
  autoFix: fix,
125321
- provider
125446
+ provider,
125447
+ workflow: resolvedConfig.workflow,
125448
+ dispatchConfirmTimeoutMs: resolvedConfig.timeouts.dispatchConfirmTimeoutMs,
125449
+ healthGracePeriodMs: resolvedConfig.timeouts.healthGracePeriodMs
125322
125450
  });
125323
125451
  issues.push(...healthFixes.map((f3) => ({ ...f3, project: project.name, role })));
125324
125452
  const orphanFixes = await scanOrphanedLabels({
@@ -125573,6 +125701,11 @@ function createChannelUnlinkTool(_ctx) {
125573
125701
  type: "string",
125574
125702
  description: "Channel ID to remove (e.g., Telegram group ID)"
125575
125703
  },
125704
+ channel: {
125705
+ type: "string",
125706
+ enum: ["telegram", "whatsapp", "discord", "slack"],
125707
+ description: "Channel type. Defaults to 'telegram'."
125708
+ },
125576
125709
  messageThreadId: {
125577
125710
  type: "number",
125578
125711
  description: "Optional Telegram topic ID. When provided, removes only that specific topic route."
@@ -125589,12 +125722,13 @@ function createChannelUnlinkTool(_ctx) {
125589
125722
  },
125590
125723
  async execute(_id, params) {
125591
125724
  const channelId = params.channelId;
125725
+ const channelType = params.channel ?? "telegram";
125592
125726
  const messageThreadId = typeof params.messageThreadId === "number" ? params.messageThreadId : void 0;
125593
125727
  const projectRef = params.project;
125594
125728
  const confirm = params.confirm;
125595
125729
  const workspaceDir = requireWorkspaceDir(toolCtx);
125596
125730
  const targetRoute = buildRouteRef({
125597
- channel: "telegram",
125731
+ channel: channelType,
125598
125732
  channelId,
125599
125733
  messageThreadId,
125600
125734
  accountId: toolCtx.agentAccountId
@@ -125912,10 +126046,14 @@ var HEARTBEAT_DEFAULTS = {
125912
126046
  intervalSeconds: 60,
125913
126047
  maxPickupsPerTick: 4
125914
126048
  };
126049
+ var DEFAULT_TICK_TIMEOUT_MS = 5e4;
125915
126050
  function resolveHeartbeatConfig(pluginConfig) {
125916
126051
  const raw = pluginConfig?.work_heartbeat;
125917
126052
  return { ...HEARTBEAT_DEFAULTS, ...raw };
125918
126053
  }
126054
+ function resolveTickTimeoutMs(config2) {
126055
+ return config2?.timeouts?.tickTimeoutMs ?? DEFAULT_TICK_TIMEOUT_MS;
126056
+ }
125919
126057
 
125920
126058
  // lib/github/process-events.ts
125921
126059
  init_zod();
@@ -130794,7 +130932,11 @@ async function reviewPass(opts) {
130794
130932
  if (routing !== "human" && routing !== "agent") continue;
130795
130933
  const isManaged = await provider.issueHasReaction(issue2.iid, "eyes");
130796
130934
  if (!isManaged) continue;
130797
- const status = await provider.getPrStatus(issue2.iid);
130935
+ const projectData = await readProjects(workspaceDir).catch(() => null);
130936
+ const project = projectData ? getProject(projectData, projectName) : null;
130937
+ const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
130938
+ const prSelector = project ? getCanonicalPrSelector(project, issue2.iid) : void 0;
130939
+ const status = await provider.getPrStatus(issue2.iid, prSelector);
130798
130940
  if (status.currentIssueMatch === false) continue;
130799
130941
  if (!status.url && status.state === PrState.CLOSED && baseBranch) {
130800
130942
  try {
@@ -130873,14 +131015,12 @@ async function reviewPass(opts) {
130873
131015
  const closedActions = typeof closedTransition === "object" ? closedTransition.actions : void 0;
130874
131016
  const targetState2 = workflow.states[targetKey2];
130875
131017
  if (targetState2) {
130876
- await resilientLabelTransition(provider, issue2.iid, state.label, targetState2.label);
131018
+ let aborted3 = false;
130877
131019
  if (closedActions) {
130878
131020
  for (const action of closedActions) {
130879
131021
  switch (action) {
130880
131022
  case Action.CLOSE_ISSUE:
130881
131023
  try {
130882
- const project = getProject(await readProjects(workspaceDir), projectName);
130883
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
130884
131024
  if (!project) throw new Error(`Project not found: ${projectName}`);
130885
131025
  await guardedCloseIssue({
130886
131026
  workspaceDir,
@@ -130889,10 +131029,12 @@ async function reviewPass(opts) {
130889
131029
  issueId: issue2.iid,
130890
131030
  role: "reviewer",
130891
131031
  provider,
131032
+ selector: prSelector,
130892
131033
  issueRuntime,
130893
131034
  followUpPrRequired: issueRuntime?.followUpPrRequired === true
130894
131035
  });
130895
131036
  } catch {
131037
+ aborted3 = true;
130896
131038
  }
130897
131039
  break;
130898
131040
  case Action.REOPEN_ISSUE:
@@ -130902,8 +131044,13 @@ async function reviewPass(opts) {
130902
131044
  }
130903
131045
  break;
130904
131046
  }
131047
+ if (aborted3) break;
130905
131048
  }
130906
131049
  }
131050
+ if (aborted3) {
131051
+ continue;
131052
+ }
131053
+ await resilientLabelTransition(provider, issue2.iid, state.label, targetState2.label);
130907
131054
  await log(workspaceDir, "review_transition", {
130908
131055
  project: projectName,
130909
131056
  issueId: issue2.iid,
@@ -130948,8 +131095,6 @@ async function reviewPass(opts) {
130948
131095
  switch (action) {
130949
131096
  case Action.MERGE_PR:
130950
131097
  if (status.state === PrState.MERGED) {
130951
- const project = getProject(await readProjects(workspaceDir), projectName);
130952
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
130953
131098
  if (project && issueRuntime?.currentPrNumber) {
130954
131099
  await persistMergedArtifact({
130955
131100
  workspaceDir,
@@ -130963,9 +131108,7 @@ async function reviewPass(opts) {
130963
131108
  break;
130964
131109
  }
130965
131110
  try {
130966
- await provider.mergePr(issue2.iid);
130967
- const project = getProject(await readProjects(workspaceDir), projectName);
130968
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131111
+ await provider.mergePr(issue2.iid, prSelector);
130969
131112
  if (project && issueRuntime?.currentPrNumber) {
130970
131113
  await persistMergedArtifact({
130971
131114
  workspaceDir,
@@ -131010,8 +131153,6 @@ async function reviewPass(opts) {
131010
131153
  break;
131011
131154
  case Action.CLOSE_ISSUE:
131012
131155
  {
131013
- const project = getProject(await readProjects(workspaceDir), projectName);
131014
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131015
131156
  if (!project) throw new Error(`Project not found: ${projectName}`);
131016
131157
  await guardedCloseIssue({
131017
131158
  workspaceDir,
@@ -131020,6 +131161,7 @@ async function reviewPass(opts) {
131020
131161
  issueId: issue2.iid,
131021
131162
  role: "reviewer",
131022
131163
  provider,
131164
+ selector: prSelector,
131023
131165
  issueRuntime,
131024
131166
  followUpPrRequired: issueRuntime?.followUpPrRequired === true
131025
131167
  });
@@ -131085,6 +131227,10 @@ async function reviewSkipPass(opts) {
131085
131227
  if (routing !== "skip") continue;
131086
131228
  const isManaged = await provider.issueHasReaction(issue2.iid, "eyes");
131087
131229
  if (!isManaged) continue;
131230
+ const projectData = await readProjects(workspaceDir).catch(() => null);
131231
+ const project = projectData ? getProject(projectData, projectName) : null;
131232
+ const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131233
+ const prSelector = project ? getCanonicalPrSelector(project, issue2.iid) : void 0;
131088
131234
  let aborted2 = false;
131089
131235
  if (actions && effectiveActions && actions.length !== effectiveActions.length) {
131090
131236
  await log(workspaceDir, "illegal_merge_before_test", {
@@ -131100,14 +131246,12 @@ async function reviewSkipPass(opts) {
131100
131246
  for (const action of effectiveActions) {
131101
131247
  switch (action) {
131102
131248
  case Action.MERGE_PR: {
131103
- const status = await provider.getPrStatus(issue2.iid);
131249
+ const status = await provider.getPrStatus(issue2.iid, prSelector);
131104
131250
  if (status.currentIssueMatch === false) {
131105
131251
  aborted2 = true;
131106
131252
  break;
131107
131253
  }
131108
131254
  if (status.state === PrState.MERGED) {
131109
- const project = getProject(await readProjects(workspaceDir), projectName);
131110
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131111
131255
  if (project && issueRuntime?.currentPrNumber) {
131112
131256
  await persistMergedArtifact({
131113
131257
  workspaceDir,
@@ -131122,9 +131266,7 @@ async function reviewSkipPass(opts) {
131122
131266
  }
131123
131267
  if (!status.url) break;
131124
131268
  try {
131125
- await provider.mergePr(issue2.iid);
131126
- const project = getProject(await readProjects(workspaceDir), projectName);
131127
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131269
+ await provider.mergePr(issue2.iid, prSelector);
131128
131270
  if (project && issueRuntime?.currentPrNumber) {
131129
131271
  await persistMergedArtifact({
131130
131272
  workspaceDir,
@@ -131163,8 +131305,6 @@ async function reviewSkipPass(opts) {
131163
131305
  break;
131164
131306
  case Action.CLOSE_ISSUE:
131165
131307
  try {
131166
- const project = getProject(await readProjects(workspaceDir), projectName);
131167
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131168
131308
  if (!project) throw new Error(`Project not found: ${projectName}`);
131169
131309
  await guardedCloseIssue({
131170
131310
  workspaceDir,
@@ -131173,10 +131313,12 @@ async function reviewSkipPass(opts) {
131173
131313
  issueId: issue2.iid,
131174
131314
  role: "reviewer",
131175
131315
  provider,
131316
+ selector: prSelector,
131176
131317
  issueRuntime,
131177
131318
  followUpPrRequired: issueRuntime?.followUpPrRequired === true
131178
131319
  });
131179
131320
  } catch {
131321
+ aborted2 = true;
131180
131322
  }
131181
131323
  break;
131182
131324
  case Action.REOPEN_ISSUE:
@@ -131223,19 +131365,21 @@ async function testSkipPass(opts) {
131223
131365
  for (const issue2 of issues) {
131224
131366
  const routing = detectStepRouting(issue2.labels, "test");
131225
131367
  if (routing !== "skip") continue;
131368
+ const projectData = await readProjects(workspaceDir).catch(() => null);
131369
+ const project = projectData ? getProject(projectData, projectName) : null;
131370
+ const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131371
+ const prSelector = project ? getCanonicalPrSelector(project, issue2.iid) : void 0;
131226
131372
  let aborted2 = false;
131227
131373
  if (actions) {
131228
131374
  for (const action of actions) {
131229
131375
  switch (action) {
131230
131376
  case Action.MERGE_PR: {
131231
- const status = await provider.getPrStatus(issue2.iid);
131377
+ const status = await provider.getPrStatus(issue2.iid, prSelector);
131232
131378
  if (status.currentIssueMatch === false) {
131233
131379
  aborted2 = true;
131234
131380
  break;
131235
131381
  }
131236
131382
  if (status.state === PrState.MERGED) {
131237
- const project = getProject(await readProjects(workspaceDir), projectName);
131238
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131239
131383
  if (project && issueRuntime?.currentPrNumber) {
131240
131384
  await persistMergedArtifact({
131241
131385
  workspaceDir,
@@ -131249,9 +131393,7 @@ async function testSkipPass(opts) {
131249
131393
  }
131250
131394
  if (!status.url) break;
131251
131395
  try {
131252
- await provider.mergePr(issue2.iid);
131253
- const project = getProject(await readProjects(workspaceDir), projectName);
131254
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131396
+ await provider.mergePr(issue2.iid, prSelector);
131255
131397
  if (project && issueRuntime?.currentPrNumber) {
131256
131398
  await persistMergedArtifact({
131257
131399
  workspaceDir,
@@ -131291,8 +131433,6 @@ async function testSkipPass(opts) {
131291
131433
  break;
131292
131434
  case Action.CLOSE_ISSUE:
131293
131435
  try {
131294
- const project = getProject(await readProjects(workspaceDir), projectName);
131295
- const issueRuntime = project ? getIssueRuntime(project, issue2.iid) : void 0;
131296
131436
  if (!project) throw new Error(`Project not found: ${projectName}`);
131297
131437
  await guardedCloseIssue({
131298
131438
  workspaceDir,
@@ -131301,10 +131441,12 @@ async function testSkipPass(opts) {
131301
131441
  issueId: issue2.iid,
131302
131442
  role: "tester",
131303
131443
  provider,
131444
+ selector: prSelector,
131304
131445
  issueRuntime,
131305
131446
  followUpPrRequired: issueRuntime?.followUpPrRequired === true
131306
131447
  });
131307
131448
  } catch {
131449
+ aborted2 = true;
131308
131450
  }
131309
131451
  break;
131310
131452
  case Action.REOPEN_ISSUE:
@@ -131401,6 +131543,146 @@ async function holdEscapePass(opts) {
131401
131543
  // lib/services/heartbeat/passes.ts
131402
131544
  init_workflow();
131403
131545
  init_audit();
131546
+ init_workflow();
131547
+ init_labels();
131548
+
131549
+ // lib/services/reviewer-completion.ts
131550
+ init_audit();
131551
+ init_labels();
131552
+ init_workflow();
131553
+
131554
+ // lib/services/reviewer-session.ts
131555
+ function extractTextContent(content) {
131556
+ if (typeof content === "string") return content;
131557
+ if (!Array.isArray(content)) return "";
131558
+ return content.filter((block) => typeof block === "object" && block != null).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
131559
+ }
131560
+ function extractReviewerDecision(text) {
131561
+ const matches2 = Array.from(text.matchAll(/^\s*Review result:\s*(APPROVE|REJECT)\s*$/gim));
131562
+ const latestMatch = matches2.at(-1);
131563
+ if (!latestMatch) return null;
131564
+ return latestMatch[1]?.toUpperCase() === "APPROVE" ? "approve" : "reject";
131565
+ }
131566
+ function extractReviewerDecisionFromMessages(messages) {
131567
+ const assistantTexts = messages.filter((message) => typeof message === "object" && message != null).filter((message) => message.role === "assistant").map((message) => extractTextContent(message.content)).filter(Boolean).reverse();
131568
+ for (const text of assistantTexts) {
131569
+ const decision = extractReviewerDecision(text);
131570
+ if (decision) return decision;
131571
+ }
131572
+ return null;
131573
+ }
131574
+ async function parseReviewerSessionResult(runtime, sessionKey) {
131575
+ try {
131576
+ const messagesResult = await runtime.subagent?.getSessionMessages?.({ sessionKey });
131577
+ if (!messagesResult) return null;
131578
+ const messages = Array.isArray(messagesResult) ? messagesResult : Array.isArray(messagesResult?.messages) ? messagesResult.messages : [];
131579
+ return extractReviewerDecisionFromMessages(messages);
131580
+ } catch {
131581
+ return null;
131582
+ }
131583
+ }
131584
+
131585
+ // lib/services/reviewer-completion.ts
131586
+ function resolveReviewerDecisionTransition(workflow, decision) {
131587
+ const activeLabel = getActiveLabel(workflow, "reviewer");
131588
+ const reviewingState = findStateByLabel(workflow, activeLabel);
131589
+ if (!reviewingState?.on) return null;
131590
+ const eventKey = decision === "approve" ? WorkflowEvent.APPROVE : WorkflowEvent.REJECT;
131591
+ const transition2 = reviewingState.on[eventKey];
131592
+ const targetKey = typeof transition2 === "string" ? transition2 : transition2?.target;
131593
+ const targetState = targetKey ? workflow.states[targetKey] : void 0;
131594
+ if (!targetKey || !targetState) return null;
131595
+ return { eventKey, targetKey, targetLabel: targetState.label };
131596
+ }
131597
+ async function handleReviewerAgentEnd(opts) {
131598
+ const eventDecision = Array.isArray(opts.messages) && opts.messages.length > 0 ? extractReviewerDecisionFromMessages(opts.messages) : null;
131599
+ const decision = eventDecision ?? (opts.runtime ? await parseReviewerSessionResult(opts.runtime, opts.sessionKey) : null);
131600
+ if (!opts.workspaceDir || !opts.runCommand) {
131601
+ return decision;
131602
+ }
131603
+ const parsed = parseFabricaSessionKey(opts.sessionKey);
131604
+ if (!parsed || parsed.role !== "reviewer") {
131605
+ return decision;
131606
+ }
131607
+ const projects = await readProjects(opts.workspaceDir);
131608
+ const projectEntry = Object.entries(projects.projects).find(([, project2]) => project2.name === parsed.projectName);
131609
+ if (!projectEntry) {
131610
+ return decision;
131611
+ }
131612
+ const [projectSlug, project] = projectEntry;
131613
+ const reviewerWorker = project.workers.reviewer;
131614
+ if (!reviewerWorker) {
131615
+ return decision;
131616
+ }
131617
+ let issueId = null;
131618
+ let slotRef = null;
131619
+ for (const [level, slots] of Object.entries(reviewerWorker.levels)) {
131620
+ const slotIndex = slots.findIndex((candidate) => candidate.sessionKey === opts.sessionKey);
131621
+ if (slotIndex >= 0) {
131622
+ const slot = slots[slotIndex];
131623
+ issueId = Number(slot.issueId ?? slot.lastIssueId ?? 0) || null;
131624
+ slotRef = { level, slotIndex, active: slot.active };
131625
+ break;
131626
+ }
131627
+ }
131628
+ if (!issueId) {
131629
+ return decision;
131630
+ }
131631
+ const { workflow } = await loadConfig(opts.workspaceDir, projectSlug);
131632
+ const activeLabel = getActiveLabel(workflow, "reviewer");
131633
+ const revertLabel = getRevertLabel(workflow, "reviewer");
131634
+ const { provider } = await createProvider({
131635
+ repo: project.repo,
131636
+ provider: project.provider,
131637
+ runCommand: opts.runCommand
131638
+ });
131639
+ const issue2 = await provider.getIssue(issueId);
131640
+ const currentLabel = issue2.labels.find((label) => label === activeLabel || label === revertLabel);
131641
+ if (!currentLabel) {
131642
+ return decision;
131643
+ }
131644
+ if (decision) {
131645
+ const transition2 = resolveReviewerDecisionTransition(workflow, decision);
131646
+ if (transition2 && transition2.targetLabel !== currentLabel) {
131647
+ await resilientLabelTransition(provider, issueId, currentLabel, transition2.targetLabel);
131648
+ if (slotRef?.active) {
131649
+ await deactivateWorker(opts.workspaceDir, projectSlug, "reviewer", {
131650
+ level: slotRef.level,
131651
+ slotIndex: slotRef.slotIndex
131652
+ });
131653
+ }
131654
+ await log(opts.workspaceDir, "reviewer_session_transition", {
131655
+ sessionKey: opts.sessionKey,
131656
+ project: parsed.projectName,
131657
+ issueId,
131658
+ result: decision,
131659
+ eventKey: transition2.eventKey,
131660
+ from: currentLabel,
131661
+ to: transition2.targetLabel
131662
+ }).catch(() => {
131663
+ });
131664
+ }
131665
+ return decision;
131666
+ }
131667
+ if (opts.fallbackToQueueOnUndetermined && currentLabel === activeLabel) {
131668
+ await provider.transitionLabel(issueId, activeLabel, revertLabel);
131669
+ if (slotRef?.active) {
131670
+ await deactivateWorker(opts.workspaceDir, projectSlug, "reviewer", {
131671
+ level: slotRef.level,
131672
+ slotIndex: slotRef.slotIndex
131673
+ });
131674
+ }
131675
+ await log(opts.workspaceDir, "reviewer_session_no_result", {
131676
+ sessionKey: opts.sessionKey,
131677
+ project: parsed.projectName,
131678
+ issueId
131679
+ }).catch(() => {
131680
+ });
131681
+ }
131682
+ return null;
131683
+ }
131684
+
131685
+ // lib/services/heartbeat/passes.ts
131404
131686
  async function fixDualStateLabels(workspaceDir, projectSlug, project, provider, resolvedConfig) {
131405
131687
  const stateLabels = getStateLabels(resolvedConfig.workflow);
131406
131688
  if (stateLabels.length === 0) return 0;
@@ -131448,24 +131730,27 @@ async function performHealthPass(workspaceDir, projectSlug, project, sessions, p
131448
131730
  for (const intent of pendingIntents) {
131449
131731
  if (intent.ts < staleThreshold) {
131450
131732
  try {
131451
- if (intent.deliveryTarget?.channelId) {
131452
- await notify(intent.data, {
131453
- workspaceDir,
131454
- config: notifyConfig,
131455
- runtime,
131456
- runCommand,
131457
- deliveryTargetOverride: intent.deliveryTarget
131458
- });
131459
- } else {
131460
- await notify(intent.data, {
131461
- workspaceDir,
131462
- config: notifyConfig,
131463
- runtime,
131464
- runCommand
131733
+ const eventType = typeof intent.data?.type === "string" ? intent.data.type : void 0;
131734
+ const notificationsEnabled = eventType ? notifyConfig[eventType] !== false : true;
131735
+ const hasDeliveryTarget = Boolean(intent.deliveryTarget?.channelId);
131736
+ const sent = intent.deliveryTarget?.channelId ? await notify(intent.data, {
131737
+ workspaceDir,
131738
+ config: notifyConfig,
131739
+ runtime,
131740
+ runCommand,
131741
+ deliveryTargetOverride: intent.deliveryTarget,
131742
+ skipOutboxWrite: true
131743
+ }) : await notify(intent.data, {
131744
+ workspaceDir,
131745
+ config: notifyConfig,
131746
+ runtime,
131747
+ runCommand,
131748
+ skipOutboxWrite: true
131749
+ });
131750
+ if (sent && hasDeliveryTarget && notificationsEnabled) {
131751
+ await markDelivered(workspaceDir, intent.key).catch(() => {
131465
131752
  });
131466
131753
  }
131467
- await markDelivered(workspaceDir, intent.key).catch(() => {
131468
- });
131469
131754
  } catch {
131470
131755
  }
131471
131756
  }
@@ -131483,7 +131768,8 @@ async function performHealthPass(workspaceDir, projectSlug, project, sessions, p
131483
131768
  provider,
131484
131769
  staleWorkerHours,
131485
131770
  workflow: resolvedConfig?.workflow,
131486
- dispatchConfirmTimeoutMs: resolvedConfig?.timeouts?.dispatchConfirmTimeoutMs
131771
+ dispatchConfirmTimeoutMs: resolvedConfig?.timeouts?.dispatchConfirmTimeoutMs,
131772
+ healthGracePeriodMs: resolvedConfig?.timeouts?.healthGracePeriodMs
131487
131773
  });
131488
131774
  fixedCount += healthFixes.filter((f3) => f3.fixed).length;
131489
131775
  const orphanFixes = await scanOrphanedLabels({
@@ -131722,6 +132008,75 @@ async function performHoldEscapePass(workspaceDir, projectSlug, project, provide
131722
132008
  }
131723
132009
  });
131724
132010
  }
132011
+ async function performReviewerPollPass(workspaceDir, projectSlug, project, provider, resolvedConfig, runtime) {
132012
+ if (!runtime) return 0;
132013
+ const workflow = resolvedConfig.workflow;
132014
+ let activeLabel;
132015
+ try {
132016
+ activeLabel = getActiveLabel(workflow, "reviewer");
132017
+ } catch {
132018
+ return 0;
132019
+ }
132020
+ const reviewerWorker = project.workers["reviewer"];
132021
+ if (!reviewerWorker) return 0;
132022
+ let transitions = 0;
132023
+ for (const [level, slots] of Object.entries(reviewerWorker.levels)) {
132024
+ for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
132025
+ const slot = slots[slotIndex];
132026
+ if (!slot.active || !slot.sessionKey || !slot.issueId) continue;
132027
+ const startTime = slot.startTime ? new Date(slot.startTime).getTime() : 0;
132028
+ if (startTime && Date.now() - startTime < 2 * 6e4) continue;
132029
+ const reviewResult = await handleReviewerAgentEnd({
132030
+ sessionKey: slot.sessionKey,
132031
+ runtime
132032
+ });
132033
+ if (!reviewResult) continue;
132034
+ const transition2 = resolveReviewerDecisionTransition(workflow, reviewResult);
132035
+ if (!transition2) continue;
132036
+ let issue2;
132037
+ try {
132038
+ issue2 = await provider.getIssue(Number(slot.issueId));
132039
+ } catch {
132040
+ continue;
132041
+ }
132042
+ if (!issue2.labels.includes(activeLabel)) {
132043
+ await deactivateWorker(workspaceDir, projectSlug, "reviewer", {
132044
+ level,
132045
+ slotIndex
132046
+ });
132047
+ await log(workspaceDir, "reviewer_poll_slot_released", {
132048
+ sessionKey: slot.sessionKey,
132049
+ project: project.name,
132050
+ projectSlug,
132051
+ issueId: slot.issueId,
132052
+ from: activeLabel,
132053
+ currentLabels: issue2.labels,
132054
+ reason: "issue_already_moved"
132055
+ }).catch(() => {
132056
+ });
132057
+ continue;
132058
+ }
132059
+ await resilientLabelTransition(provider, Number(slot.issueId), activeLabel, transition2.targetLabel);
132060
+ await deactivateWorker(workspaceDir, projectSlug, "reviewer", {
132061
+ level,
132062
+ slotIndex
132063
+ });
132064
+ await log(workspaceDir, "reviewer_poll_transition", {
132065
+ sessionKey: slot.sessionKey,
132066
+ project: project.name,
132067
+ projectSlug,
132068
+ issueId: slot.issueId,
132069
+ result: reviewResult,
132070
+ eventKey: transition2.eventKey,
132071
+ from: activeLabel,
132072
+ to: transition2.targetLabel
132073
+ }).catch(() => {
132074
+ });
132075
+ transitions++;
132076
+ }
132077
+ }
132078
+ return transitions;
132079
+ }
131725
132080
 
131726
132081
  // lib/github/pr-event-source.ts
131727
132082
  function buildRunId2(installationId, repositoryId, prNumber, headSha) {
@@ -131828,7 +132183,8 @@ async function runPrDiscoveryPass(params) {
131828
132183
  }
131829
132184
  for (const { issueId } of activeSlots) {
131830
132185
  try {
131831
- const prDetails = await params.provider.getPrDetails(issueId);
132186
+ const prSelector = getCanonicalPrSelector(params.project, issueId);
132187
+ const prDetails = await params.provider.getPrDetails(issueId, prSelector);
131832
132188
  if (!prDetails) {
131833
132189
  result.skipped++;
131834
132190
  continue;
@@ -131955,52 +132311,6 @@ async function checkGenesisHealth(workspaceDir) {
131955
132311
  }
131956
132312
  }
131957
132313
 
131958
- // lib/observability/health-score.ts
131959
- function computeHealthScore(input) {
131960
- const signals = [];
131961
- let totalWeight = 0;
131962
- let weightedSum = 0;
131963
- let hasMeaningfulData = false;
131964
- function addSignal(name, raw, weight, meaningful = true) {
131965
- if (raw === null) {
131966
- signals.push({ name, raw, weighted: 0 });
131967
- return;
131968
- }
131969
- if (meaningful) hasMeaningfulData = true;
131970
- const clamped = Math.max(0, Math.min(1, raw));
131971
- const weighted = clamped * weight * 100;
131972
- signals.push({ name, raw, weighted });
131973
- totalWeight += weight;
131974
- weightedSum += weighted;
131975
- }
131976
- addSignal("completion_rate", input.completionRate, 0.25);
131977
- const speedRatio = input.avgDispatchToCompletionMinutes !== null && input.avgDispatchToCompletionMinutes > 0 ? Math.min(1, input.baselineMinutes / input.avgDispatchToCompletionMinutes) : null;
131978
- addSignal("dispatch_speed", speedRatio, 0.2);
131979
- const errorScore = input.errorRate !== null ? 1 - input.errorRate : null;
131980
- addSignal("error_rate", errorScore, 0.2);
131981
- const queueScore = input.maxQueueDepth > 0 ? 1 - Math.min(1, input.queueDepth / input.maxQueueDepth) : 1;
131982
- addSignal("queue_depth", queueScore, 0.15, false);
131983
- addSignal("heartbeat_regularity", input.heartbeatRegularity, 0.2);
131984
- if (!hasMeaningfulData) {
131985
- return { score: 50, status: "degraded", signals };
131986
- }
131987
- const score = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 50;
131988
- const status = score > 80 ? "healthy" : score >= 50 ? "degraded" : "unhealthy";
131989
- return { score, status, signals };
131990
- }
131991
-
131992
- // lib/observability/alerting.ts
131993
- function shouldAlert(score, threshold, state, now2) {
131994
- if (score < threshold) {
131995
- if (now2 - state.lastAlertTs < state.cooldownMs) return "skip";
131996
- return "alert";
131997
- }
131998
- if (state.lastAlertScore < threshold && score >= 80) {
131999
- return "recovered";
132000
- }
132001
- return "skip";
132002
- }
132003
-
132004
132314
  // lib/observability/tracer.ts
132005
132315
  init_esm();
132006
132316
  var TRACER_NAME = "fabrica.heartbeat";
@@ -132023,8 +132333,6 @@ async function withTelemetrySpan2(name, fn) {
132023
132333
 
132024
132334
  // lib/services/heartbeat/tick-runner.ts
132025
132335
  var discoveredProjects = /* @__PURE__ */ new Set();
132026
- var _tickCount = 0;
132027
- var _alertState = { lastAlertTs: 0, lastAlertScore: 100, cooldownMs: 18e5 };
132028
132336
  function validateWorkflowIntegrity2(workflow) {
132029
132337
  const errors = [];
132030
132338
  const stateKeys = new Set(Object.keys(workflow.states));
@@ -132119,6 +132427,14 @@ async function tick(opts) {
132119
132427
  }).catch((err) => {
132120
132428
  opts.logger.warn?.(`PR discovery pass failed for ${slug}: ${err.message}`);
132121
132429
  });
132430
+ result.totalReviewTransitions += await performReviewerPollPass(
132431
+ workspaceDir,
132432
+ slug,
132433
+ project,
132434
+ provider,
132435
+ resolvedConfig,
132436
+ runtime
132437
+ );
132122
132438
  result.totalReviewTransitions += await performReviewPass(
132123
132439
  workspaceDir,
132124
132440
  slug,
@@ -132200,35 +132516,6 @@ async function tick(opts) {
132200
132516
  pickups: result.totalPickups,
132201
132517
  skipped: result.totalSkipped
132202
132518
  });
132203
- _tickCount++;
132204
- if (_tickCount % 10 === 0) {
132205
- const healthScore = computeHealthScore({
132206
- completionRate: slugs.length > 0 ? result.totalPickups / Math.max(1, slugs.length) : null,
132207
- avgDispatchToCompletionMinutes: null,
132208
- baselineMinutes: 30,
132209
- errorRate: slugs.length > 0 ? result.totalHealthFixes / Math.max(1, slugs.length) : null,
132210
- queueDepth: result.totalSkipped,
132211
- maxQueueDepth: Math.max(20, slugs.length * 3),
132212
- heartbeatRegularity: null
132213
- });
132214
- await log(workspaceDir, "health_score", {
132215
- score: healthScore.score,
132216
- status: healthScore.status,
132217
- tickCount: _tickCount
132218
- }).catch(() => {
132219
- });
132220
- const decision = shouldAlert(healthScore.score, 60, _alertState, Date.now());
132221
- if (decision === "alert" || decision === "recovered") {
132222
- _alertState.lastAlertTs = Date.now();
132223
- _alertState.lastAlertScore = healthScore.score;
132224
- await log(workspaceDir, "health_alert", {
132225
- decision,
132226
- score: healthScore.score,
132227
- status: healthScore.status
132228
- }).catch(() => {
132229
- });
132230
- }
132231
- }
132232
132519
  return result;
132233
132520
  }
132234
132521
  async function checkProjectActive(workspaceDir, slug) {
@@ -132288,7 +132575,6 @@ function registerHeartbeatService(api, pluginCtx) {
132288
132575
  }
132289
132576
  });
132290
132577
  }
132291
- var DEFAULT_TICK_TIMEOUT_MS = 5e4;
132292
132578
  var _ticksTimedOut = 0;
132293
132579
  async function withTickMutex(fn) {
132294
132580
  if (_anyTickRunning) return "busy";
@@ -132312,6 +132598,8 @@ async function runHeartbeatTick(ctx, logger6, mode) {
132312
132598
  let timedOut = false;
132313
132599
  try {
132314
132600
  const workspace = discoverAgents(ctx.config)[0]?.workspace;
132601
+ const resolvedWorkspaceConfig = workspace ? await loadConfig(workspace).catch(() => null) : null;
132602
+ const tickTimeoutMs = resolveTickTimeoutMs(resolvedWorkspaceConfig);
132315
132603
  const lifecycle = workspace ? await getLifecycleService(workspace, logger6) : null;
132316
132604
  const run = () => ctx.observability.withContext(
132317
132605
  { phase: `heartbeat:${mode}` },
@@ -132344,10 +132632,10 @@ async function runHeartbeatTick(ctx, logger6, mode) {
132344
132632
  }
132345
132633
  };
132346
132634
  const HARD_TICK_TIMEOUT_MS = 5 * 6e4;
132347
- const raceResult = await raceWithTimeout(wrappedTickFn, DEFAULT_TICK_TIMEOUT_MS, () => {
132635
+ const raceResult = await raceWithTimeout(wrappedTickFn, tickTimeoutMs, () => {
132348
132636
  _ticksTimedOut++;
132349
132637
  timedOut = true;
132350
- logger6.warn(`work_heartbeat ${mode} tick timed out after ${DEFAULT_TICK_TIMEOUT_MS}ms (total timeouts: ${_ticksTimedOut})`);
132638
+ logger6.warn(`work_heartbeat ${mode} tick timed out after ${tickTimeoutMs}ms (total timeouts: ${_ticksTimedOut})`);
132351
132639
  const hardTimeout = setTimeout(() => {
132352
132640
  logger6.error("tick_mutex: hard timeout \u2014 forcing mutex release");
132353
132641
  _tickRunning[mode] = false;
@@ -135758,6 +136046,7 @@ var registerStep = {
135758
136046
  const resolvedName = name ?? fail("Missing project name or repository target for registration");
135759
136047
  const resolvedRepo = repo ?? fail("Missing project name or repository target for registration");
135760
136048
  const resolvedChannelId = channelId ?? fail("Missing channel binding for project registration");
136049
+ const createProjectTopic = payload.metadata.source === "telegram-dm-bootstrap";
135761
136050
  try {
135762
136051
  const programmaticSources = ["telegram-dm-bootstrap", "genesis-trigger-script"];
135763
136052
  const projectWorkflowConfig = programmaticSources.includes(payload.metadata.source ?? "") ? { workflow: { reviewPolicy: "agent" } } : void 0;
@@ -135776,7 +136065,7 @@ var registerStep = {
135776
136065
  pluginConfig: ctx.pluginConfig,
135777
136066
  baseBranch,
135778
136067
  deployBranch: baseBranch,
135779
- createProjectTopic: payload.metadata.source === "telegram-dm-bootstrap",
136068
+ createProjectTopic,
135780
136069
  projectWorkflowConfig
135781
136070
  });
135782
136071
  if (programmaticSources.includes(payload.metadata.source ?? "") && output.activeWorkflow.reviewPolicy !== "agent") {
@@ -135800,6 +136089,7 @@ var registerStep = {
135800
136089
  metadata: {
135801
136090
  ...payload.metadata,
135802
136091
  project_registered: output.success,
136092
+ project_topic_created: createProjectTopic && output.success && output.messageThreadId != null,
135803
136093
  project_slug: output.projectSlug ?? payload.metadata.project_slug,
135804
136094
  repo_path: resolvedRepo,
135805
136095
  channel_id: output.channelId,
@@ -136341,7 +136631,7 @@ async function runPipeline(initialPayload, ctx) {
136341
136631
  ctx.log(`Step ${step.name} completed in ${stepDuration}ms`);
136342
136632
  } catch (err) {
136343
136633
  ctx.log(`Step ${step.name} FAILED: ${String(err)}`);
136344
- const failureArtifacts = deriveArtifacts(payload);
136634
+ const failureArtifacts = mergeArtifacts(deriveArtifacts(payload), extractErrorArtifacts(err));
136345
136635
  if (failureArtifacts.length > 0) {
136346
136636
  const cleanupResults = await cleanupArtifacts(failureArtifacts, {
136347
136637
  log: ctx.log,
@@ -136383,6 +136673,12 @@ function deriveArtifacts(payload) {
136383
136673
  const provider = payload.provisioning.provider === "gitlab" ? "gitlab_repo" : "github_repo";
136384
136674
  artifacts.push({ type: provider, id: payload.provisioning.repo_url });
136385
136675
  }
136676
+ if (payload.metadata.project_topic_created === true && payload.metadata.channel_id && payload.metadata.message_thread_id != null) {
136677
+ artifacts.push({
136678
+ type: "forum_topic",
136679
+ id: buildForumTopicArtifactId(payload.metadata.channel_id, payload.metadata.message_thread_id)
136680
+ });
136681
+ }
136386
136682
  if (payload.issues?.length) {
136387
136683
  for (const issue2 of payload.issues) {
136388
136684
  artifacts.push({ type: "github_issue", id: String(issue2.number) });
@@ -136390,6 +136686,31 @@ function deriveArtifacts(payload) {
136390
136686
  }
136391
136687
  return artifacts;
136392
136688
  }
136689
+ function extractErrorArtifacts(err) {
136690
+ const artifacts = err?.artifacts;
136691
+ if (!Array.isArray(artifacts)) {
136692
+ return [];
136693
+ }
136694
+ return artifacts.filter(isPipelineArtifact);
136695
+ }
136696
+ function isPipelineArtifact(value) {
136697
+ if (!value || typeof value !== "object") {
136698
+ return false;
136699
+ }
136700
+ const artifact = value;
136701
+ return typeof artifact.id === "string" && (artifact.type === "github_repo" || artifact.type === "gitlab_repo" || artifact.type === "forum_topic" || artifact.type === "github_issue");
136702
+ }
136703
+ function mergeArtifacts(primary, secondary) {
136704
+ const merged = [];
136705
+ const seen = /* @__PURE__ */ new Set();
136706
+ for (const artifact of [...primary, ...secondary]) {
136707
+ const key = `${artifact.type}:${artifact.id}`;
136708
+ if (seen.has(key)) continue;
136709
+ seen.add(key);
136710
+ merged.push(artifact);
136711
+ }
136712
+ return merged;
136713
+ }
136393
136714
 
136394
136715
  // lib/tools/admin/genesis.ts
136395
136716
  import { homedir as homedir2 } from "node:os";
@@ -136398,7 +136719,7 @@ import { homedir as homedir2 } from "node:os";
136398
136719
  import { createHash as createHash5, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
136399
136720
  import { existsSync, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
136400
136721
  import { join as join3 } from "node:path";
136401
- import { randomUUID as randomUUID3 } from "node:crypto";
136722
+ import { randomUUID as randomUUID4 } from "node:crypto";
136402
136723
  var GENESIS_TOKEN_VERSION = 1;
136403
136724
  var GENESIS_TOKEN_PREFIX = "g1";
136404
136725
  var GENESIS_TOKEN_SECRET_FILE = ".commit-token-secret";
@@ -136580,7 +136901,7 @@ function normalizeGenesisRequest(params, existingPayload) {
136580
136901
  const dryRun = typeof params.dry_run === "boolean" ? params.dry_run : existingPayload?.dry_run ?? false;
136581
136902
  return {
136582
136903
  phase: phaseCandidate,
136583
- sessionId: normalizeOptionalString(params.session_id) ?? existingPayload?.session_id ?? randomUUID3(),
136904
+ sessionId: normalizeOptionalString(params.session_id) ?? existingPayload?.session_id ?? randomUUID4(),
136584
136905
  rawIdea: normalizeOptionalString(params.idea) ?? command ?? (existingPayload?.raw_idea ? existingPayload.raw_idea : void 0),
136585
136906
  answers,
136586
136907
  answersJson,
@@ -136941,13 +137262,18 @@ async function handleCommit(params, normalized, ctx) {
136941
137262
  }
136942
137263
  };
136943
137264
  const result = await runPipeline(payload, ctx);
137265
+ const projectRegistered = result.payload.metadata.project_registered === true;
137266
+ const hasRunnableWork = (result.payload.issues?.length ?? 0) > 0 || result.payload.triage?.ready_for_dispatch === true;
137267
+ const programmaticCommitFailedClosed = !dryRun && result.success && !projectRegistered && !hasRunnableWork;
137268
+ const success2 = programmaticCommitFailedClosed ? false : result.success;
137269
+ const error48 = programmaticCommitFailedClosed ? "Programmatic commit produced no registered project and no runnable work" : result.error;
136944
137270
  return jsonResult25({
136945
- success: result.success,
137271
+ success: success2,
136946
137272
  session_id: result.payload.session_id,
136947
137273
  steps_executed: result.steps_executed,
136948
137274
  steps_skipped: result.steps_skipped,
136949
137275
  duration_ms: result.duration_ms,
136950
- error: result.error,
137276
+ error: error48,
136951
137277
  spec: result.payload.spec,
136952
137278
  scaffold: result.payload.scaffold,
136953
137279
  qa_contract: result.payload.qa_contract,
@@ -138349,7 +138675,7 @@ init_zod();
138349
138675
 
138350
138676
  // lib/dispatch/telegram-bootstrap-session.ts
138351
138677
  init_constants();
138352
- import { createHash as createHash6 } from "node:crypto";
138678
+ import { createHash as createHash6, randomUUID as randomUUID5 } from "node:crypto";
138353
138679
  import fs39 from "node:fs/promises";
138354
138680
  import path41 from "node:path";
138355
138681
  var SESSION_TTL_MS = 10 * 6e4;
@@ -138405,7 +138731,7 @@ async function writeTelegramBootstrapSession(workspaceDir, session) {
138405
138731
  const dir = sessionsDir(workspaceDir);
138406
138732
  await fs39.mkdir(dir, { recursive: true });
138407
138733
  const file2 = sessionPath(workspaceDir, session.conversationId);
138408
- const tmp = `${file2}.tmp`;
138734
+ const tmp = `${file2}.${randomUUID5()}.tmp`;
138409
138735
  await fs39.writeFile(tmp, JSON.stringify(session, null, 2) + "\n", "utf-8");
138410
138736
  await fs39.rename(tmp, file2);
138411
138737
  }
@@ -138718,6 +139044,54 @@ function logBootstrapWarning(ctx, message) {
138718
139044
  ctx.logger.info(message);
138719
139045
  }
138720
139046
  }
139047
+ async function runBootstrapPreflightOrFail(ctx, conversationId, workspaceDir, request, sourceRoute, options) {
139048
+ const telegramConfig = readFabricaTelegramConfig(ctx.pluginConfig);
139049
+ const existingSession = await readTelegramBootstrapSession(workspaceDir, conversationId);
139050
+ const language = options?.language ?? existingSession?.language ?? "pt";
139051
+ if (!telegramConfig.projectsForumChatId) {
139052
+ await upsertTelegramBootstrapSession(workspaceDir, {
139053
+ conversationId,
139054
+ rawIdea: request.rawIdea,
139055
+ projectName: request.projectName ?? void 0,
139056
+ stackHint: request.stackHint ?? void 0,
139057
+ repoUrl: request.repoUrl ?? void 0,
139058
+ repoPath: request.repoPath ?? void 0,
139059
+ sourceRoute,
139060
+ status: "failed",
139061
+ language,
139062
+ error: "missing_projects_forum_chat"
139063
+ });
139064
+ await sendTelegramText(
139065
+ ctx,
139066
+ conversationId,
139067
+ "A Fabrica precisa de um grupo de projetos configurado para criar projetos automaticamente. Configure 'telegram.projectsForumChatId' no openclaw.json do plugin."
139068
+ );
139069
+ return true;
139070
+ }
139071
+ const candidateSlug = inferProjectSlug(request.projectName ?? request.rawIdea);
139072
+ if (!candidateSlug) return false;
139073
+ const projects = await readProjects(workspaceDir).catch(() => null);
139074
+ if (!projects?.projects?.[candidateSlug]) return false;
139075
+ await upsertTelegramBootstrapSession(workspaceDir, {
139076
+ conversationId,
139077
+ rawIdea: request.rawIdea,
139078
+ projectName: request.projectName ?? void 0,
139079
+ stackHint: request.stackHint ?? void 0,
139080
+ repoUrl: request.repoUrl ?? void 0,
139081
+ repoPath: request.repoPath ?? void 0,
139082
+ sourceRoute,
139083
+ status: "failed",
139084
+ projectSlug: candidateSlug,
139085
+ language,
139086
+ error: "duplicate_project_slug"
139087
+ });
139088
+ await sendTelegramText(
139089
+ ctx,
139090
+ conversationId,
139091
+ `Ja existe um projeto registrado com o slug "${candidateSlug}". Use o fluxo administrativo para vincular canais ou ajustar o projeto existente.`
139092
+ );
139093
+ return true;
139094
+ }
138721
139095
  async function classifyAndBootstrap(ctx, workspaceDir, conversationId, content) {
138722
139096
  await upsertTelegramBootstrapSession(workspaceDir, {
138723
139097
  conversationId,
@@ -138734,7 +139108,6 @@ async function classifyAndBootstrap(ctx, workspaceDir, conversationId, content)
138734
139108
  return;
138735
139109
  }
138736
139110
  const language = classification.language ?? "pt";
138737
- await sendTelegramText(ctx, conversationId, BOOTSTRAP_MESSAGES.ack[language]);
138738
139111
  const parsed = parseBootstrapRequest(content);
138739
139112
  if (classification.stackHint && !parsed.stackHint) {
138740
139113
  parsed.stackHint = classification.stackHint;
@@ -138763,6 +139136,18 @@ async function classifyAndBootstrap(ctx, workspaceDir, conversationId, content)
138763
139136
  }
138764
139137
  }
138765
139138
  const sourceRoute = { channel: "telegram", channelId: conversationId };
139139
+ if (parsed.stackHint) {
139140
+ const handled = await runBootstrapPreflightOrFail(
139141
+ ctx,
139142
+ conversationId,
139143
+ workspaceDir,
139144
+ incomingRequest,
139145
+ sourceRoute,
139146
+ { language }
139147
+ );
139148
+ if (handled) return;
139149
+ }
139150
+ await sendTelegramText(ctx, conversationId, BOOTSTRAP_MESSAGES.ack[language]);
138766
139151
  const session = await upsertTelegramBootstrapSession(workspaceDir, {
138767
139152
  conversationId,
138768
139153
  ...incomingRequest,
@@ -138805,15 +139190,10 @@ function bootstrapWithTimeout(ctx, conversationId, workspaceDir, request, source
138805
139190
  });
138806
139191
  }
138807
139192
  async function continueBootstrap(ctx, conversationId, workspaceDir, request, sourceRoute) {
138808
- const telegramConfig = readFabricaTelegramConfig(ctx.pluginConfig);
138809
- if (!telegramConfig.projectsForumChatId) {
138810
- await sendTelegramText(
138811
- ctx,
138812
- conversationId,
138813
- "A Fabrica precisa de um grupo de projetos configurado para criar projetos automaticamente. Configure 'telegram.projectsForumChatId' no openclaw.json do plugin."
138814
- );
139193
+ if (await runBootstrapPreflightOrFail(ctx, conversationId, workspaceDir, request, sourceRoute)) {
138815
139194
  return;
138816
139195
  }
139196
+ const telegramConfig = readFabricaTelegramConfig(ctx.pluginConfig);
138817
139197
  const stackHint = request.stackHint;
138818
139198
  if (!stackHint) {
138819
139199
  const existingSession = await readTelegramBootstrapSession(workspaceDir, conversationId);
@@ -138864,18 +139244,6 @@ async function continueBootstrap(ctx, conversationId, workspaceDir, request, sou
138864
139244
  }
138865
139245
  }
138866
139246
  }
138867
- const candidateSlug = inferProjectSlug(request.projectName ?? request.rawIdea);
138868
- if (candidateSlug) {
138869
- const projects = await readProjects(workspaceDir).catch(() => null);
138870
- if (projects?.projects?.[candidateSlug]) {
138871
- await sendTelegramText(
138872
- ctx,
138873
- conversationId,
138874
- `Ja existe um projeto registrado com o slug "${candidateSlug}". Use o fluxo administrativo para vincular canais ou ajustar o projeto existente.`
138875
- );
138876
- return;
138877
- }
138878
- }
138879
139247
  const stepCtx = {
138880
139248
  runCommand: async (cmd, args, opts) => {
138881
139249
  const result2 = await ctx.runCommand([cmd, ...args], {
@@ -138931,7 +139299,7 @@ async function continueBootstrap(ctx, conversationId, workspaceDir, request, sou
138931
139299
  const result = await runPipeline(payload, stepCtx);
138932
139300
  if (!result.success) {
138933
139301
  const orphanedRepoArtifact = result.artifacts?.find((a) => a.type === "github_repo" || a.type === "gitlab_repo");
138934
- const projectRegistered = result.payload?.project_registered === true;
139302
+ const projectRegistered = result.payload?.metadata?.project_registered === true;
138935
139303
  if (orphanedRepoArtifact && !projectRegistered) {
138936
139304
  await upsertTelegramBootstrapSession(workspaceDir, {
138937
139305
  conversationId,
@@ -139121,6 +139489,20 @@ function registerTelegramBootstrapHook(api, ctx) {
139121
139489
  repoPath: existingSession.repoPath ?? null
139122
139490
  };
139123
139491
  ctx.logger.info(`[telegram-bootstrap] clarification resolved: stack=${mergedRequest.stackHint}, idea="${mergedRequest.rawIdea}" (conversation: ${conversationId})`);
139492
+ if (mergedRequest.stackHint) {
139493
+ const handled2 = await runBootstrapPreflightOrFail(
139494
+ ctx,
139495
+ conversationId,
139496
+ workspaceDir,
139497
+ mergedRequest,
139498
+ existingSession.sourceRoute ?? {
139499
+ channel: "telegram",
139500
+ channelId: conversationId
139501
+ },
139502
+ { language: existingSession.language ?? "pt" }
139503
+ );
139504
+ if (handled2) return;
139505
+ }
139124
139506
  bootstrapWithTimeout(ctx, conversationId, workspaceDir, mergedRequest, existingSession.sourceRoute ?? {
139125
139507
  channel: "telegram",
139126
139508
  channelId: conversationId
@@ -139185,19 +139567,19 @@ function registerTelegramBootstrapHook(api, ctx) {
139185
139567
  repoPath: parsed.repoPath ?? null
139186
139568
  };
139187
139569
  const language = /\b(cria|crie|criar|construa|desenvolva|registre|novo projeto)\b/i.test(content) ? "pt" : "en";
139188
- await sendTelegramText(ctx, conversationId, BOOTSTRAP_MESSAGES.ack[language]);
139189
- const session = await upsertTelegramBootstrapSession(workspaceDir, {
139190
- conversationId,
139191
- ...incomingRequest,
139192
- sourceRoute: {
139193
- channel: "telegram",
139194
- channelId: conversationId
139195
- },
139196
- sourceChannel: "telegram",
139197
- status: "received",
139198
- language
139199
- });
139200
139570
  if (!parsed.stackHint) {
139571
+ await sendTelegramText(ctx, conversationId, BOOTSTRAP_MESSAGES.ack[language]);
139572
+ const session = await upsertTelegramBootstrapSession(workspaceDir, {
139573
+ conversationId,
139574
+ ...incomingRequest,
139575
+ sourceRoute: {
139576
+ channel: "telegram",
139577
+ channelId: conversationId
139578
+ },
139579
+ sourceChannel: "telegram",
139580
+ status: "received",
139581
+ language
139582
+ });
139201
139583
  const pendingClarification = !parsed.projectName ? "stack_and_name" : "stack";
139202
139584
  await upsertTelegramBootstrapSession(workspaceDir, {
139203
139585
  conversationId,
@@ -139210,6 +139592,30 @@ function registerTelegramBootstrapHook(api, ctx) {
139210
139592
  await sendTelegramText(ctx, conversationId, buildClarificationMessage(parsed, pendingClarification, language));
139211
139593
  return;
139212
139594
  }
139595
+ const handled = await runBootstrapPreflightOrFail(
139596
+ ctx,
139597
+ conversationId,
139598
+ workspaceDir,
139599
+ incomingRequest,
139600
+ {
139601
+ channel: "telegram",
139602
+ channelId: conversationId
139603
+ },
139604
+ { language }
139605
+ );
139606
+ if (handled) return;
139607
+ await sendTelegramText(ctx, conversationId, BOOTSTRAP_MESSAGES.ack[language]);
139608
+ await upsertTelegramBootstrapSession(workspaceDir, {
139609
+ conversationId,
139610
+ ...incomingRequest,
139611
+ sourceRoute: {
139612
+ channel: "telegram",
139613
+ channelId: conversationId
139614
+ },
139615
+ sourceChannel: "telegram",
139616
+ status: "received",
139617
+ language
139618
+ });
139213
139619
  bootstrapWithTimeout(ctx, conversationId, workspaceDir, incomingRequest, {
139214
139620
  channel: "telegram",
139215
139621
  channelId: conversationId
@@ -139529,7 +139935,7 @@ function registerGatewayLifecycleHook(api, ctx) {
139529
139935
  init_audit();
139530
139936
 
139531
139937
  // lib/dispatch/reactive-dispatch-hook.ts
139532
- var COMPLETION_TOOLS = /* @__PURE__ */ new Set(["work_finish", "review_submit"]);
139938
+ var COMPLETION_TOOLS = /* @__PURE__ */ new Set(["work_finish"]);
139533
139939
  var spawnTimes = /* @__PURE__ */ new Map();
139534
139940
  function getSpawnTime(sessionKey) {
139535
139941
  return spawnTimes.get(sessionKey);
@@ -139538,6 +139944,18 @@ function clearSpawnTime(sessionKey) {
139538
139944
  spawnTimes.delete(sessionKey);
139539
139945
  }
139540
139946
  function registerReactiveDispatchHooks(api, ctx) {
139947
+ const workspaceDir = resolveWorkspaceDir(ctx.config);
139948
+ api.on("before_tool_call", async (event, eventCtx) => {
139949
+ if (event.toolName !== "work_finish") return;
139950
+ const runId = eventCtx.runId ?? event.runId;
139951
+ if (!runId) return;
139952
+ return {
139953
+ params: {
139954
+ ...event.params,
139955
+ _dispatchRunId: runId
139956
+ }
139957
+ };
139958
+ });
139541
139959
  api.on("after_tool_call", async (event, _eventCtx) => {
139542
139960
  if (!COMPLETION_TOOLS.has(event.toolName)) return;
139543
139961
  ctx.runtime?.system.requestHeartbeatNow({ reason: "work_finish", coalesceMs: 2e3 });
@@ -139557,6 +139975,10 @@ function registerReactiveDispatchHooks(api, ctx) {
139557
139975
  const sessionKey = event.childSessionKey;
139558
139976
  if (!sessionKey) return;
139559
139977
  spawnTimes.set(sessionKey, Date.now());
139978
+ if (workspaceDir && event.runId) {
139979
+ await bindDispatchRunIdBySessionKey(workspaceDir, sessionKey, event.runId).catch(() => {
139980
+ });
139981
+ }
139560
139982
  });
139561
139983
  }
139562
139984
 
@@ -139599,7 +140021,7 @@ function registerSubagentLifecycleHook(api, ctx) {
139599
140021
  let foundSlot;
139600
140022
  for (const [level, slots] of Object.entries(roleWorker.levels)) {
139601
140023
  for (let i2 = 0; i2 < slots.length; i2++) {
139602
- if (slots[i2].sessionKey === sessionKey && slots[i2].active) {
140024
+ if (slots[i2].sessionKey === sessionKey) {
139603
140025
  foundLevel = level;
139604
140026
  foundSlotIndex = i2;
139605
140027
  foundSlot = slots[i2];
@@ -139609,24 +140031,88 @@ function registerSubagentLifecycleHook(api, ctx) {
139609
140031
  if (foundSlot) break;
139610
140032
  }
139611
140033
  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
- });
140034
+ const issueId = foundSlot.issueId ?? foundSlot.lastIssueId;
140035
+ const issueRuntime = issueId ? project.issueRuntime?.[String(issueId)] : void 0;
140036
+ const currentDispatchRunId = foundSlot.dispatchRunId ?? issueRuntime?.dispatchRunId ?? null;
140037
+ const currentDispatchCycleId = foundSlot.dispatchCycleId ?? issueRuntime?.lastDispatchCycleId ?? null;
140038
+ if (event.runId && currentDispatchRunId && event.runId !== currentDispatchRunId) {
140039
+ await log(workspaceDir, "subagent_ended_slot_cleanup_rejected", {
140040
+ sessionKey,
140041
+ project: projectName,
140042
+ projectSlug,
140043
+ role,
140044
+ level: foundLevel,
140045
+ slotIndex: foundSlotIndex,
140046
+ issueId,
140047
+ reason: "stale_dispatch_cycle",
140048
+ eventRunId: event.runId,
140049
+ currentDispatchRunId,
140050
+ currentDispatchCycleId
140051
+ }).catch(() => {
140052
+ });
140053
+ return;
140054
+ }
140055
+ if (foundSlot.dispatchCycleId && issueRuntime?.lastDispatchCycleId && foundSlot.dispatchCycleId !== issueRuntime.lastDispatchCycleId) {
140056
+ await log(workspaceDir, "subagent_ended_slot_cleanup_rejected", {
140057
+ sessionKey,
140058
+ project: projectName,
140059
+ projectSlug,
140060
+ role,
140061
+ level: foundLevel,
140062
+ slotIndex: foundSlotIndex,
140063
+ issueId,
140064
+ reason: "stale_dispatch_cycle",
140065
+ eventRunId: event.runId ?? null,
140066
+ currentDispatchRunId,
140067
+ slotDispatchCycleId: foundSlot.dispatchCycleId,
140068
+ runtimeDispatchCycleId: issueRuntime.lastDispatchCycleId
140069
+ }).catch(() => {
140070
+ });
140071
+ return;
140072
+ }
140073
+ if (foundSlot.active) {
140074
+ await deactivateWorker(workspaceDir, projectSlug, role, {
140075
+ level: foundLevel,
140076
+ slotIndex: foundSlotIndex
140077
+ });
140078
+ }
139617
140079
  if (issueId) {
139618
140080
  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);
140081
+ if (role === "reviewer") {
140082
+ const reviewResult = await handleReviewerAgentEnd({
140083
+ sessionKey,
140084
+ runtime: ctx.runtime,
140085
+ workspaceDir,
140086
+ runCommand: ctx.runCommand,
140087
+ fallbackToQueueOnUndetermined: true
140088
+ });
140089
+ if (!reviewResult) {
140090
+ const { workflow } = await loadConfig(workspaceDir, projectSlug);
140091
+ const activeLabel = getActiveLabel(workflow, role);
140092
+ const revertLabel = getRevertLabel(workflow, role);
140093
+ const { provider } = await createProvider({
140094
+ repo: project.repo,
140095
+ provider: project.provider,
140096
+ runCommand: ctx.runCommand
140097
+ });
140098
+ const issue2 = await provider.getIssue(Number(issueId));
140099
+ if (issue2.labels.includes(activeLabel)) {
140100
+ await provider.transitionLabel(Number(issueId), activeLabel, revertLabel);
140101
+ }
140102
+ }
140103
+ } else {
140104
+ const { workflow } = await loadConfig(workspaceDir, projectSlug);
140105
+ const activeLabel = getActiveLabel(workflow, role);
140106
+ const revertLabel = getRevertLabel(workflow, role);
140107
+ const { provider } = await createProvider({
140108
+ repo: project.repo,
140109
+ provider: project.provider,
140110
+ runCommand: ctx.runCommand
140111
+ });
140112
+ const issue2 = await provider.getIssue(Number(issueId));
140113
+ if (issue2.labels.includes(activeLabel)) {
140114
+ await provider.transitionLabel(Number(issueId), activeLabel, revertLabel);
140115
+ }
139630
140116
  }
139631
140117
  } catch {
139632
140118
  }
@@ -139711,13 +140197,25 @@ When you have finished your task, you MUST call the \`work_finish\` tool to sign
139711
140197
  Do NOT rely on your session ending automatically \u2014 you must explicitly call \`work_finish\`.
139712
140198
  This is required for the pipeline to advance to the next stage.
139713
140199
  `;
140200
+ var REVIEWER_COMPLETION_CONTEXT = `## Task Completion
140201
+
140202
+ When you finish the review, signal completion by ending your response with the decision line below.
140203
+ End your response with exactly one decision line in plain text:
140204
+ - \`Review result: APPROVE\`
140205
+ - \`Review result: REJECT\`
140206
+
140207
+ The orchestrator reads that line directly from your response and advances the review stage automatically.
140208
+ If you need the project slug for follow-up tools such as \`task_create\`, use the value from the \`Channel:\` line in the task message.
140209
+ `;
139714
140210
  function registerWorkerContextHook(api, _ctx) {
139715
140211
  api.on("before_agent_start", async (_event, eventCtx) => {
139716
140212
  const sessionKey = eventCtx.sessionKey;
139717
140213
  if (!sessionKey) return;
139718
140214
  const parsed = parseFabricaSessionKey(sessionKey);
139719
140215
  if (!parsed) return;
139720
- return { prependSystemContext: WORK_FINISH_CONTEXT };
140216
+ return {
140217
+ prependSystemContext: parsed.role === "reviewer" ? REVIEWER_COMPLETION_CONTEXT : WORK_FINISH_CONTEXT
140218
+ };
139721
140219
  });
139722
140220
  }
139723
140221
 
@@ -139758,14 +140256,30 @@ var plugin = {
139758
140256
  type: "string",
139759
140257
  description: "Environment variable containing the GitHub App webhook secret."
139760
140258
  },
140259
+ webhookSecret: {
140260
+ type: "string",
140261
+ description: "Inline GitHub App webhook secret."
140262
+ },
140263
+ webhookSecretPath: {
140264
+ type: "string",
140265
+ description: "Filesystem path containing the GitHub App webhook secret."
140266
+ },
140267
+ webhookMode: {
140268
+ type: "string",
140269
+ enum: ["required", "optional", "disabled"],
140270
+ description: "Controls GitHub webhook route registration behavior."
140271
+ },
139761
140272
  authProfiles: {
139762
140273
  type: "object",
139763
140274
  additionalProperties: {
139764
140275
  type: "object",
139765
140276
  properties: {
139766
140277
  mode: { type: "string", enum: ["github-app"] },
140278
+ appId: { type: "string" },
139767
140279
  appIdEnv: { type: "string" },
140280
+ privateKey: { type: "string" },
139768
140281
  privateKeyEnv: { type: "string" },
140282
+ privateKeyPath: { type: "string" },
139769
140283
  privateKeyPathEnv: { type: "string" },
139770
140284
  baseUrl: { type: "string" },
139771
140285
  fallbackMode: { type: "string", enum: ["pr-conversation-comment"] },