fraim-hub 2.0.240 → 2.0.241

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.
@@ -734,7 +734,8 @@ function resolveHostInvocation(plan) {
734
734
  // cannot drift on how the managed-agent bin directories are put on PATH.
735
735
  const versionProbeEnv = () => ({
736
736
  ...process.env,
737
- PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH),
737
+ PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
738
+ Path: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
738
739
  });
739
740
  const availableByVersionProbe = (command) => {
740
741
  const invocation = resolveHostInvocation({ command, args: ['--version'] });
@@ -806,10 +807,20 @@ const EMPLOYEE_DETECTION_TTL_MS = 5 * 60 * 1000;
806
807
  let employeeDetectionTtlMs = EMPLOYEE_DETECTION_TTL_MS;
807
808
  let cachedEmployees = null;
808
809
  let cachedEmployeesAtMs = 0;
810
+ let cachedEmployeesContext = null;
809
811
  let inFlightDetection = null;
812
+ function employeeDetectionContext() {
813
+ return JSON.stringify({
814
+ PATH: process.env.PATH || '',
815
+ Path: process.env.Path || '',
816
+ FRAIM_USER_DIR: process.env.FRAIM_USER_DIR || '',
817
+ });
818
+ }
810
819
  function cachedEmployeesIfFresh() {
811
820
  if (!cachedEmployees)
812
821
  return null;
822
+ if (cachedEmployeesContext !== employeeDetectionContext())
823
+ return null;
813
824
  if (Date.now() - cachedEmployeesAtMs > employeeDetectionTtlMs)
814
825
  return null;
815
826
  return cachedEmployees;
@@ -831,6 +842,7 @@ function cachedEmployeesIfFresh() {
831
842
  // TTL lapses. That is recoverable and self-correcting, and is preferable to freezing every
832
843
  // cold start for 2.2s. Installs performed THROUGH the Hub invalidate explicitly.
833
844
  const AGENT_AVAILABILITY_FILE = 'hub-agent-availability.json';
845
+ const AGENT_AVAILABILITY_SCHEMA_VERSION = 2;
834
846
  // Overridable so tests do not mutate the developer's real ~/.fraim state. These guards run
835
847
  // in the smoke suite now, and invalidation DELETES this file, so pointing them at a temp
836
848
  // path keeps a frequent test run from repeatedly clearing real agent-availability data.
@@ -850,6 +862,10 @@ function loadPersistedEmployees() {
850
862
  try {
851
863
  const raw = fs_1.default.readFileSync(agentAvailabilityFilePath(), 'utf8');
852
864
  const parsed = JSON.parse(raw);
865
+ if (parsed.schemaVersion !== AGENT_AVAILABILITY_SCHEMA_VERSION)
866
+ return null;
867
+ if (parsed.detectionContext !== employeeDetectionContext())
868
+ return null;
853
869
  if (!Array.isArray(parsed.employees) || parsed.employees.length === 0)
854
870
  return null;
855
871
  // Only accept entries that still match the current known agent ids, so a stale file
@@ -880,6 +896,7 @@ function loadPersistedEmployees() {
880
896
  function adoptPersistedEmployees(persisted) {
881
897
  cachedEmployees = persisted.employees;
882
898
  cachedEmployeesAtMs = persisted.detectedAtMs;
899
+ cachedEmployeesContext = employeeDetectionContext();
883
900
  if (Date.now() - persisted.detectedAtMs > employeeDetectionTtlMs) {
884
901
  void detectEmployeesAsync({ force: true }).catch(() => undefined);
885
902
  }
@@ -889,7 +906,12 @@ function persistEmployees(employees) {
889
906
  try {
890
907
  const file = agentAvailabilityFilePath();
891
908
  fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
892
- fs_1.default.writeFileSync(file, JSON.stringify({ employees, detectedAt: new Date().toISOString() }, null, 2), 'utf8');
909
+ fs_1.default.writeFileSync(file, JSON.stringify({
910
+ employees,
911
+ detectedAt: new Date().toISOString(),
912
+ detectionContext: employeeDetectionContext(),
913
+ schemaVersion: AGENT_AVAILABILITY_SCHEMA_VERSION,
914
+ }, null, 2), 'utf8');
893
915
  }
894
916
  catch {
895
917
  // Best effort only: failing to persist must never break detection.
@@ -898,6 +920,7 @@ function persistEmployees(employees) {
898
920
  function storeDetectedEmployees(employees) {
899
921
  cachedEmployees = employees;
900
922
  cachedEmployeesAtMs = Date.now();
923
+ cachedEmployeesContext = employeeDetectionContext();
901
924
  persistEmployees(employees);
902
925
  return employees;
903
926
  }
@@ -909,6 +932,7 @@ function storeDetectedEmployees(employees) {
909
932
  function invalidateEmployeeDetectionCache() {
910
933
  cachedEmployees = null;
911
934
  cachedEmployeesAtMs = 0;
935
+ cachedEmployeesContext = null;
912
936
  inFlightDetection = null;
913
937
  try {
914
938
  fs_1.default.rmSync(agentAvailabilityFilePath(), { force: true });
@@ -53,9 +53,10 @@ function buildSameJobContinueMessage(coaching) {
53
53
  ? `Continue the active FRAIM job with this manager coaching:\n\n${remainder}`
54
54
  : 'Continue the active FRAIM job using the current session context.';
55
55
  }
56
- function buildManagerMessage(employeeId, jobId, kind, instructions, stubPath) {
56
+ function buildManagerMessage(employeeId, jobId, kind, instructions, options) {
57
+ const stubPath = options?.stubPath;
57
58
  const trimmed = String(instructions || '').trim();
58
- const explicit = extractExplicitFraimInvocation(trimmed);
59
+ const explicit = options?.ignoreEmbeddedInvocation ? null : extractExplicitFraimInvocation(trimmed);
59
60
  const effectiveJobId = explicit?.jobId || jobId;
60
61
  const invocation = fraimInvocationFor(employeeId, effectiveJobId);
61
62
  if (!invocation && effectiveJobId === '__freeform__' && kind === 'start') {
@@ -749,6 +749,9 @@ function normalizeDelegationLedger(raw) {
749
749
  status,
750
750
  personaKey: cleanNullableString((rawTask.personaKey || rawTask.persona)),
751
751
  jobId: cleanNullableString((rawTask.jobId || rawTask.job || rawTask.job_id)),
752
+ // Issue #1021: accept the same alias tolerance the rest of this normalizer applies,
753
+ // and coerce a numeric issue to a string so `#${issueNumber}` never renders `[object Object]`.
754
+ issueNumber: cleanNullableString((rawTask.issueNumber ?? rawTask.issue_number ?? rawTask.issue)?.toString()),
752
755
  reviewJobId: cleanNullableString(rawTask.reviewJobId),
753
756
  reviewType: cleanNullableString(rawTask.reviewType),
754
757
  instructions: cleanString((rawTask.instructions || rawTask.briefing)) || undefined,
@@ -2711,7 +2714,17 @@ class AiHubServer {
2711
2714
  continue;
2712
2715
  if (task.status && task.status !== 'planned')
2713
2716
  continue;
2714
- if (!task.jobId || !this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
2717
+ // Issue #1021: a task that names no job is a manager-visible defect, not a
2718
+ // silent skip. The unresolvable-job path below already blocks with a reason;
2719
+ // this makes the no-job-at-all case behave identically instead of vanishing.
2720
+ // Dependency waiting stays a silent continue — that task is pending, not broken.
2721
+ if (!task.jobId) {
2722
+ this.markDelegationTaskBlocked(managerRun, task, `Delegation blocked: task "${task.taskId}" does not name a job.`);
2723
+ started.add(task.taskId);
2724
+ managerRun.orchestratedDelegationTaskIds.push(task.taskId);
2725
+ continue;
2726
+ }
2727
+ if (!this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
2715
2728
  continue;
2716
2729
  if (!task.personaKey)
2717
2730
  task.personaKey = getProtectedPersonaForHubJob(task.jobId);
@@ -2720,6 +2733,21 @@ class AiHubServer {
2720
2733
  managerRun.orchestratedDelegationTaskIds.push(task.taskId);
2721
2734
  }
2722
2735
  }
2736
+ /**
2737
+ * Issue #1021: one place that marks a delegation task blocked. Extracted so the
2738
+ * no-job and unresolvable-job paths cannot drift apart — two hand-written copies
2739
+ * of this transition is how the original inconsistency arose.
2740
+ *
2741
+ * Sets a status and a one-line reason only. Per issue #826 a protocol defect must
2742
+ * not surface as manager decision UI, so this deliberately creates no review
2743
+ * controls and no correction affordance.
2744
+ */
2745
+ markDelegationTaskBlocked(managerRun, task, reason) {
2746
+ task.status = 'blocked';
2747
+ task.latestSummary = reason;
2748
+ managerRun.events.push((0, hosts_1.createHubEvent)('system', reason));
2749
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
2750
+ }
2723
2751
  delegationDependenciesSatisfied(ledger, dependsOn) {
2724
2752
  if (!dependsOn.length)
2725
2753
  return true;
@@ -2738,27 +2766,40 @@ class AiHubServer {
2738
2766
  task.personaKey = getProtectedPersonaForHubJob(task.jobId);
2739
2767
  const resolvedJob = this.resolveHubJob(managerRun.projectPath, task.jobId);
2740
2768
  if (!resolvedJob) {
2741
- task.status = 'blocked';
2742
- task.latestSummary = `Delegation blocked: job "${task.jobId}" is not available in this project.`;
2743
- managerRun.events.push((0, hosts_1.createHubEvent)('system', task.latestSummary));
2744
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
2769
+ this.markDelegationTaskBlocked(managerRun, task, `Delegation blocked: job "${task.jobId}" is not available in this project.`);
2745
2770
  return;
2746
2771
  }
2747
2772
  const childConversationId = task.conversationId || `${managerRun.id}-${task.taskId}`;
2748
2773
  const now = new Date().toISOString();
2749
- const childInstructions = [
2750
- task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
2751
- '',
2774
+ // Issue #1021: this brief adds context the job cannot know and subtracts nothing.
2775
+ // It previously carried "This is a delegated subtask, not an end-to-end FRAIM
2776
+ // submission workflow ... Do not create evidence docs, open or update PRs, update
2777
+ // GitHub issues" plus a "submit a concise summary back to Mandy" substitute for the
2778
+ // job's real submit phase. Both are gone: the child runs its named job in full, and
2779
+ // delegation changes who reviews the output, not what the output is.
2780
+ //
2781
+ // Nothing is added in their place. The Hub does not restate what the job already
2782
+ // instructs (#563), and it does not tell the child who reviews it — routing is
2783
+ // handled entirely by notifyManagerOfDelegatedChild and, on the client, by
2784
+ // syncManagedDelegationAccess. The retained "Mandy is your manager" line is the only
2785
+ // one the child's behavior actually depends on: it stops the child blocking on a human.
2786
+ const contextLines = [
2752
2787
  task.personaKey
2753
2788
  ? `You are working as ${task.personaKey} for the manager job.`
2754
2789
  : 'You are working as the specialist assigned by this delegated job.',
2755
2790
  `Parent objective: ${ledger.objective}.`,
2756
- task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : '',
2757
- 'Submit a concise deliverable summary and any artifact references back to Mandy.',
2758
- 'This is a delegated subtask, not an end-to-end FRAIM submission workflow. After you provide the deliverable for Mandy, stop. Do not create evidence docs, open or update PRs, update GitHub issues, or ask the human for review.',
2791
+ // The issue the child works, carried as ledger data rather than left to prose, so
2792
+ // set-up-workspace can provision the right branch and the job can label the issue.
2793
+ task.issueNumber ? `Issue: #${task.issueNumber}` : null,
2794
+ task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : null,
2759
2795
  'Do not ask the human for coaching; Mandy is your manager for this workstream.',
2796
+ ].filter((line) => Boolean(line));
2797
+ const childInstructions = [
2798
+ task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
2799
+ '',
2800
+ ...contextLines,
2760
2801
  ].join('\n');
2761
- const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions);
2802
+ const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions, { ignoreEmbeddedInvocation: true });
2762
2803
  const childRun = {
2763
2804
  id: (0, crypto_1.randomUUID)(),
2764
2805
  conversationId: childConversationId,
@@ -3092,8 +3133,15 @@ class AiHubServer {
3092
3133
  // Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
3093
3134
  // content) export routes so a conversational deliverable with no on-disk file
3094
3135
  // can still be downloaded for Word annotation.
3095
- prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
3096
- const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
3136
+ prepareStartPayload(projectPath, hostId, selectedJobId, instructions,
3137
+ // Issue #1021: an explicit invocation inside `instructions` normally wins, which is
3138
+ // correct when a human typed `/fraim <job>` into the coach box. For an
3139
+ // orchestration-initiated launch there is no human typing: the instructions are
3140
+ // agent-generated prose, and the delegation ledger is the only authority on which
3141
+ // job runs. Without this, a stray `/fraim other-job` in Mandy's brief would silently
3142
+ // launch a different job than the ledger declares and stamp it onto childRun.jobId.
3143
+ options) {
3144
+ const explicit = options?.ignoreEmbeddedInvocation ? null : (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
3097
3145
  const resolvedJobId = explicit?.jobId || selectedJobId;
3098
3146
  if (!resolvedJobId) {
3099
3147
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
@@ -3107,11 +3155,12 @@ class AiHubServer {
3107
3155
  // available (env published at boot).
3108
3156
  const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
3109
3157
  const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
3158
+ const ignoreEmbedded = options?.ignoreEmbeddedInvocation === true;
3110
3159
  if (resolvedJobId === '__freeform__') {
3111
- const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
3160
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
3112
3161
  return {
3113
3162
  jobId: resolvedJobId,
3114
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
3163
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
3115
3164
  display,
3116
3165
  };
3117
3166
  }
@@ -3119,10 +3168,10 @@ class AiHubServer {
3119
3168
  const absoluteStubPath = resolvedJob?.stubPath
3120
3169
  ? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
3121
3170
  : undefined;
3122
- const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
3171
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
3123
3172
  return {
3124
3173
  jobId: resolvedJobId,
3125
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
3174
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { stubPath: absoluteStubPath, ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
3126
3175
  display,
3127
3176
  };
3128
3177
  }
@@ -4585,12 +4634,17 @@ class AiHubServer {
4585
4634
  if (!run) {
4586
4635
  return res.status(404).json({ error: 'Run not found.' });
4587
4636
  }
4588
- if (!run.sessionId) {
4589
- return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
4637
+ // When sessionId is null the host session is gone (agent crashed, Hub restart with no
4638
+ // resume path, etc.). A non-managed direct run must still accept coaching: fall back to a
4639
+ // fresh startRun with the conversation's handoff context + the manager's coaching text,
4640
+ // exactly as the agent-switch path does. Managed delegation children are still blocked —
4641
+ // their coaching flows through the parent manager run, not through the human.
4642
+ if (!run.sessionId && !run.humanCoachingDisabled && run.status === 'running') {
4643
+ return res.status(409).json({ error: 'This run has not established a host session yet. Wait a moment and try again.' });
4590
4644
  }
4591
4645
  const instructions = (req.body.instructions || '').trim();
4592
4646
  const coachingJobId = req.body.coachingJobId?.trim() || undefined;
4593
- // When coachingJobId is present (user picked a manager template via the UI),
4647
+ // When coachingJobId is present (user picked a manager coaching template via the UI),
4594
4648
  // it overrides the run's own jobId in the invocation. The server always adds
4595
4649
  // the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
4596
4650
  const prepared = instructions
@@ -4602,6 +4656,70 @@ class AiHubServer {
4602
4656
  if (!message) {
4603
4657
  return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
4604
4658
  }
4659
+ // No resumable session — start fresh using a handoff prompt so the agent
4660
+ // picks up from the preserved conversation state + manager coaching.
4661
+ if (!run.sessionId) {
4662
+ if (run.humanCoachingDisabled) {
4663
+ return res.status(403).json({ error: 'Human coaching is disabled for this delegated workstream. Coach the parent manager run instead.' });
4664
+ }
4665
+ const conversation = this.conversationStore.loadConversation((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id);
4666
+ const handoffSummary = conversation
4667
+ ? buildAgentSwitchHandoffSummary(conversation)
4668
+ : null;
4669
+ const freshPayload = handoffSummary
4670
+ ? this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, renderAgentSwitchHandoffPrompt(handoffSummary, prepared.display || message))
4671
+ : this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, prepared.display || message);
4672
+ const reviewApprovalSystemEventTextFresh = buildReviewApprovalSystemEventText(prepared.display || message);
4673
+ this.runRegistry.update(run.id, (current) => {
4674
+ current.status = 'running';
4675
+ current.sessionId = undefined;
4676
+ current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
4677
+ if (reviewApprovalSystemEventTextFresh)
4678
+ current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventTextFresh));
4679
+ current.events.push((0, hosts_1.createHubEvent)('system', 'No resumable session — starting a fresh agent turn from conversation context.'));
4680
+ });
4681
+ const startedFresh = this.runRegistry.get(run.id);
4682
+ if (startedFresh)
4683
+ this.persistRunConversation(startedFresh, startedFresh.conversationId || startedFresh.id);
4684
+ this.runRegistry.create(run, {});
4685
+ const freshLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
4686
+ const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message, {
4687
+ onEvent: (event, channel) => {
4688
+ this.runRegistry.update(run.id, (current) => {
4689
+ if (event.sessionId) {
4690
+ current.sessionId = event.sessionId;
4691
+ current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
4692
+ }
4693
+ appendHostMessage(current, run.hostId, event, channel);
4694
+ if (event.raw) {
4695
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
4696
+ applyReviewProjection(current, event.raw);
4697
+ }
4698
+ if (event.agentIdentity)
4699
+ applyAgentIdentitySignal(current, event.agentIdentity);
4700
+ if (event.fraimJob)
4701
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
4702
+ if (event.seekMentoring)
4703
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
4704
+ if (event.usage)
4705
+ applyUsageSignal(current, event.usage);
4706
+ });
4707
+ const updated = this.runRegistry.get(run.id);
4708
+ if (updated) {
4709
+ this.maybeStartDelegatedChildRuns(updated);
4710
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
4711
+ }
4712
+ },
4713
+ onExit: (exitCode) => {
4714
+ this.handleRunExit(run.id, exitCode, (updated) => {
4715
+ this.maybeStartDelegatedChildRuns(updated);
4716
+ });
4717
+ },
4718
+ }, startSessionSeedForHost(run.hostId, run.id), freshLaunch.launchContext);
4719
+ this.runRegistry.attachChildIfRunning(run.id, freshChild);
4720
+ const refreshedFresh = this.runRegistry.get(run.id);
4721
+ return res.json(refreshedFresh ? this.enrichRunForResponse(refreshedFresh) : refreshedFresh);
4722
+ }
4605
4723
  const reviewApprovalSystemEventText = buildReviewApprovalSystemEventText(prepared.display || message);
4606
4724
  this.runRegistry.update(run.id, (current) => {
4607
4725
  current.status = 'running';
@@ -5425,9 +5543,6 @@ class AiHubServer {
5425
5543
  console.log(`[ai-hub] scheduled deployment ${deployment.id} skipped - scheduled fire already claimed`);
5426
5544
  return;
5427
5545
  }
5428
- if (process.env.FRAIM_DEBUG_SCHEDULER === '1') {
5429
- console.log('[ai-hub] debug claimed scheduled fire', deployment.id, deployment.hostId, deployment.activeRunId || null);
5430
- }
5431
5546
  await this.fireDeploymentRun(deployment);
5432
5547
  }
5433
5548
  catch (err) {
@@ -5438,7 +5553,7 @@ class AiHubServer {
5438
5553
  const exactFireMs = exactSixFieldCronDateMs(deployment.cronExpr);
5439
5554
  const exactFireNearDue = exactFireMs !== null && exactFireMs - Date.now() <= 5_000;
5440
5555
  if (exactFireNearDue) {
5441
- setTimeout(() => { void fireScheduledDeployment(exactFireMs); }, 0);
5556
+ setTimeout(() => { void fireScheduledDeployment(exactFireMs); }, 1_000);
5442
5557
  }
5443
5558
  const exactTimer = exactFireMs !== null && !exactFireNearDue
5444
5559
  ? setTimeout(() => { void fireScheduledDeployment(exactFireMs); }, Math.max(0, exactFireMs - Date.now()))
@@ -5506,9 +5621,6 @@ class AiHubServer {
5506
5621
  // Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
5507
5622
  // can call runRegistry.update without "Run not found" throws.
5508
5623
  this.runRegistry.create(run, {});
5509
- if (process.env.FRAIM_DEBUG_SCHEDULER === '1') {
5510
- console.log('[ai-hub] debug starting deployment run', deployment.id, hostId, deployment.projectPath);
5511
- }
5512
5624
  const child = this.hostRuntime.startRun(hostId, deployment.projectPath, instructions, {
5513
5625
  onEvent: (event, channel) => {
5514
5626
  this.runRegistry.update(run.id, (current) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.240",
3
+ "version": "2.0.241",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -161,7 +161,7 @@
161
161
  "electron": "^41.2.2",
162
162
  "electron-updater": "^6.8.9",
163
163
  "express": "^5.2.1",
164
- "fraim": "2.0.240",
164
+ "fraim": "2.0.241",
165
165
  "mongodb": "^7.0.0",
166
166
  "node-cron": "4.2.1",
167
167
  "node-edge-tts": "^1.2.10",
@@ -1242,7 +1242,14 @@ function panelStateFor(convId) {
1242
1242
  }
1243
1243
 
1244
1244
  function defaultCoachOpen(conv) {
1245
- if (isManagerOversightConversation(conv)) return false;
1245
+ // Issue #1021 (R11): an oversight run collapses the coach panel by default so the
1246
+ // delegation boards get the vertical space. But the review actions live inside that
1247
+ // panel, so once Mandy submits, the human had to expand a collapsed panel to find
1248
+ // Approve while every other employee shows it immediately. Parity is the whole point
1249
+ // of this issue: when the run is actually awaiting a decision, the decision is visible.
1250
+ if (isManagerOversightConversation(conv)) {
1251
+ return typeof convAwaitingReview === 'function' ? convAwaitingReview(conv) : false;
1252
+ }
1246
1253
  return true;
1247
1254
  }
1248
1255
 
@@ -3649,6 +3656,32 @@ function delegatedTaskArtifacts(task, childConv) {
3649
3656
  return task.artifacts || [];
3650
3657
  }
3651
3658
 
3659
+ // Issue #1021: a delegated child's pull request is its review surface. The URL is
3660
+ // AI-derived (it originates in an agent-emitted review handoff), so it must pass the
3661
+ // shared safeHttpUrl gate before it can become an href — the same trust boundary
3662
+ // window-open-decision.ts enforces for the desktop shell. Never hand-roll this check.
3663
+ function delegatedTaskPullRequestTarget(task, childConv) {
3664
+ const target = childConv?.reviewHandoff?.reviewTarget || task?.reviewHandoff?.reviewTarget;
3665
+ if (!target || target.type !== 'pull_request') return null;
3666
+ const url = safeHttpUrl(target.url);
3667
+ return url ? { target, url } : null;
3668
+ }
3669
+
3670
+ // Label for a child's pull request chip. Prefers the normalized `prNumber` the review
3671
+ // handoff already carries, matching the existing `PR #${target.prNumber}` convention used
3672
+ // by the delivery-action summary, rather than re-deriving it. Falls back to parsing the
3673
+ // URL for a provider that did not supply a number, then to the target's own label.
3674
+ function pullRequestChipLabel(target, url) {
3675
+ const prNumber = target && typeof target.prNumber === 'number' && Number.isFinite(target.prNumber)
3676
+ ? target.prNumber
3677
+ : null;
3678
+ if (prNumber) return `Pull request #${prNumber}`;
3679
+ const match = /\/(?:pull|pull-requests|merge_requests)\/(\d+)/.exec(String(url || ''));
3680
+ if (match) return `Pull request #${match[1]}`;
3681
+ const label = target && typeof target.label === 'string' ? target.label.trim() : '';
3682
+ return label || 'Pull request';
3683
+ }
3684
+
3652
3685
  function renderDelegationLedger(conv) {
3653
3686
  const host = ensureDelegationHost();
3654
3687
  if (!host) return;
@@ -3732,7 +3765,26 @@ function renderDelegationLedger(conv) {
3732
3765
  if (detail.textContent) row.appendChild(detail);
3733
3766
  row.appendChild(summary);
3734
3767
 
3768
+ // Issue #1021: a child that submits a pull request carries `artifacts: []` by
3769
+ // contract (a PR target IS the whole review surface), so the artifact strip below
3770
+ // was empty and the row fell through to "no file artifact reported" — actively
3771
+ // wrong once delegated children open real PRs. Surface the PR as the child's
3772
+ // review surface in the same strip, using the same component class.
3773
+ const childPr = delegatedTaskPullRequestTarget(task, childConv);
3735
3774
  const artifacts = delegatedTaskArtifacts(task, childConv);
3775
+ if (childPr) {
3776
+ const prStrip = document.createElement('div');
3777
+ prStrip.className = 'delegation-artifacts';
3778
+ const link = document.createElement('a');
3779
+ link.className = 'delegation-artifact';
3780
+ link.href = childPr.url;
3781
+ link.target = '_blank';
3782
+ link.rel = 'noopener noreferrer';
3783
+ link.textContent = pullRequestChipLabel(childPr.target, childPr.url);
3784
+ link.title = childPr.url;
3785
+ prStrip.appendChild(link);
3786
+ row.appendChild(prStrip);
3787
+ }
3736
3788
  if (artifacts.length) {
3737
3789
  const artifactStrip = document.createElement('div');
3738
3790
  artifactStrip.className = 'delegation-artifacts';
@@ -3752,7 +3804,7 @@ function renderDelegationLedger(conv) {
3752
3804
  artifactStrip.appendChild(chip);
3753
3805
  }
3754
3806
  row.appendChild(artifactStrip);
3755
- } else if (childConv?.status === 'completed' || taskStatus === 'submitted' || taskStatus === 'reviewed') {
3807
+ } else if (!childPr && (childConv?.status === 'completed' || taskStatus === 'submitted' || taskStatus === 'reviewed')) {
3756
3808
  const inline = document.createElement('div');
3757
3809
  inline.className = 'delegation-review-note';
3758
3810
  inline.textContent = 'Inline deliverable; no file artifact reported';
@@ -6841,10 +6893,11 @@ async function continueRun(text, options) {
6841
6893
  body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}) }),
6842
6894
  });
6843
6895
  } catch (e) {
6896
+ const isNotFound = /not found/i.test((e && e.message) || '');
6844
6897
  // #521: the Hub run is in-memory and is lost on a server restart, but the
6845
6898
  // agent session persists on disk. If the run is gone and we have a sessionId,
6846
6899
  // resume the conversation rather than failing — carries it forward intact.
6847
- if (conv.sessionId && /not found/i.test((e && e.message) || '')) {
6900
+ if (conv.sessionId && isNotFound) {
6848
6901
  run = await requestJson('/api/ai-hub/runs/resume', {
6849
6902
  method: 'POST',
6850
6903
  headers: { 'Content-Type': 'application/json' },
@@ -6862,6 +6915,26 @@ async function continueRun(text, options) {
6862
6915
  }),
6863
6916
  });
6864
6917
  conv.runId = run.id; // bind the conversation to the freshly-resumed run
6918
+ } else if (!conv.sessionId && (isNotFound || /409/.test((e && e.message) || ''))) {
6919
+ // Run is gone from the registry AND there is no resumable session (agent crashed
6920
+ // or Hub restarted after a failed run). Start a completely fresh run bound to
6921
+ // this conversation so the manager's coaching is not lost.
6922
+ run = await requestJson('/api/ai-hub/runs', {
6923
+ method: 'POST',
6924
+ headers: { 'Content-Type': 'application/json' },
6925
+ body: JSON.stringify({
6926
+ projectPath: state.projectPath,
6927
+ hostId: baseHostIdForAgent(conversationAgentName(conv) || state.selectedEmployeeId || 'claude'),
6928
+ configuredAgentId: conversationAgentName(conv) || state.selectedEmployeeId || 'claude',
6929
+ jobId: conv.jobId,
6930
+ jobTitle: conv.jobTitle || conv.jobId,
6931
+ conversationId: conv.id,
6932
+ conversationTitle: conv.title,
6933
+ instructions: text,
6934
+ ...(coachingJobId ? { coachingJobId } : {}),
6935
+ }),
6936
+ });
6937
+ conv.runId = run.id; // bind the conversation to the new run
6865
6938
  } else {
6866
6939
  throw e;
6867
6940
  }
@@ -1277,6 +1277,17 @@ img.conv-employee-avatar {
1277
1277
  .delegation-artifact:hover {
1278
1278
  background: color-mix(in srgb, var(--accent) 16%, transparent);
1279
1279
  }
1280
+ /* Issue #1021: a child's pull request renders as an <a> in this strip, so the chip is
1281
+ now keyboard-navigable where it was previously only a <button> relying on the UA
1282
+ ring. Match the focus-visible convention used across this stylesheet. */
1283
+ .delegation-artifact:focus-visible,
1284
+ .delegation-view-work:focus-visible {
1285
+ outline: 2px solid var(--accent-strong);
1286
+ outline-offset: 2px;
1287
+ }
1288
+ .delegation-artifact {
1289
+ text-decoration: none;
1290
+ }
1280
1291
  .delegation-review-note {
1281
1292
  background: var(--state-working-soft);
1282
1293
  color: var(--state-working);