From 9e9983a09a7a301280e83d63cc80cf0030ef589d Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 27 Jul 2026 14:22:11 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20raise=20Fullstack=20QA=20Loop=20cycl?= =?UTF-8?q?ic=20channel=20maxCycles=206=20=E2=86=92=2050?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR lsm/HyperNeo#2262 hit the previous maxCycles: 6 cap on round 7 of a legitimate review loop — the Reviewer's in-band send_message to Coding was rejected with "Cyclic channel ... has reached the maximum cycle count (6)". 7 rounds is legitimate for a non-trivial PR; a cap of 6 is too tight. Raise maxCycles 6 → 50 on both Fullstack QA Loop cyclic back-channels: - Review → Coding (feedback) - QA → Coding (issues found) Add a regression test pinning both channels to maxCycles: 50 (and a behavioral guard > 6) so the cap cannot silently regress. Scope decisions: - Runtime fallback default `?? 5` (channel-router.ts, space-runtime.ts) is intentionally untouched — it is a separate concern from the explicit `6` on this template. - Other built-in workflows' explicit maxCycles: 5 are left as-is. - Change applies to NEW workflow runs only. Existing in-flight runs seeded with maxCycles: 6 in the DB keep their old cap (the actionable feedback for the triggering run was already rerouted via the GitHub PR review, so no data was lost). No DB migration. --- .../lib/space/workflows/built-in-workflows.ts | 4 ++-- .../workflow/built-in-workflows.test.ts | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/lib/space/workflows/built-in-workflows.ts b/packages/daemon/src/lib/space/workflows/built-in-workflows.ts index ec5a96f08..0d84ba22e 100644 --- a/packages/daemon/src/lib/space/workflows/built-in-workflows.ts +++ b/packages/daemon/src/lib/space/workflows/built-in-workflows.ts @@ -1244,13 +1244,13 @@ export const FULLSTACK_QA_LOOP_WORKFLOW: SpaceWorkflow = { { from: 'Review', to: 'Coding', - maxCycles: 6, + maxCycles: 50, label: 'Review → Coding (feedback)', }, { from: 'QA', to: 'Coding', - maxCycles: 6, + maxCycles: 50, label: 'QA → Coding (issues found)', }, ], diff --git a/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts b/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts index 9e70b13f1..2e1b9c7e6 100644 --- a/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts +++ b/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts @@ -6139,6 +6139,27 @@ test('FULLSTACK_QA_LOOP_WORKFLOW QA node prompt contains Terminal Action Pre-con ); }); +// Regression: PR lsm/HyperNeo#2262 hit the previous maxCycles: 6 cap on +// round 7 of a legitimate review loop, blocking the in-band Review → Coding +// handoff. Both cyclic back-channels must permit well beyond 6 cycles. +test('FULLSTACK_QA_LOOP_WORKFLOW cyclic back-channels permit more than 6 review/QA cycles', () => { + const reviewToCoding = FULLSTACK_QA_LOOP_WORKFLOW.channels!.find( + (c) => c.from === 'Review' && c.to === 'Coding' + ); + const qaToCoding = FULLSTACK_QA_LOOP_WORKFLOW.channels!.find( + (c) => c.from === 'QA' && c.to === 'Coding' + ); + expect(reviewToCoding).toBeDefined(); + expect(qaToCoding).toBeDefined(); + // Explicit cap raised from 6 → 50; pin the exact value so a future + // regression to the old tight cap (or below) is caught immediately. + expect(reviewToCoding!.maxCycles).toBe(50); + expect(qaToCoding!.maxCycles).toBe(50); + // Behavioral guard: both channels must permit more than 6 cycles. + expect(reviewToCoding!.maxCycles).toBeGreaterThan(6); + expect(qaToCoding!.maxCycles).toBeGreaterThan(6); +}); + test('PLAN_AND_DECOMPOSE_WORKFLOW Plan Review reviewers carry Terminal Action Pre-conditions', () => { const reviewNode = PLAN_AND_DECOMPOSE_WORKFLOW.nodes.find((n) => n.name === 'Plan Review')!; expect(reviewNode.agents).toHaveLength(4); From 0c6a1f57e9c8259f7b9a0069ed31aeec286417bd Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 27 Jul 2026 14:39:36 -0400 Subject: [PATCH 2/3] fix: propagate template channel maxCycles/label onto existing spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #2275: raising the template cap 6 → 50 alone does NOT reach existing spaces. mergeChannelsFromTemplate matched channels by {from,to,gateId} and returned existing channels verbatim, so the Fullstack back-channels kept maxCycles: 6. Meanwhile the re-stamp wrote templateHash unconditionally but wrote `channels` only on add/remove — so on the first restart the hash was bumped to the 50-hash while the channels stayed at 6, and the matching hash then skipped the row on every future startup. Net effect: the increase reached only newly-created spaces; every existing space kept maxCycles:6 permanently with no self-heal path. Fix: - mergeChannelsFromTemplate now merges structural fields (maxCycles, label) in-place onto channels matched by {from,to,gateId}, mirroring the gate (writers/features) and node-agent (toolGuards) merges. - seedBuiltInWorkflows now persists `channels` whenever the merge changed anything (added channels OR updated structural fields), not only on add/remove — via a JSON-equality check against the pre-merge channels. - RESTAMP_FIELDS doc + the channels bullet updated to reflect the in-place channel merge. Test: re-stamp regression that seeds Fullstack QA Loop, regresses both back-channels to maxCycles: 6, stamps a stale hash, re-runs the seeder, and asserts the persisted channels are now maxCycles: 50. Verified to fail (Received: 6) without the propagation fix. --- .../lib/space/workflows/built-in-workflows.ts | 64 +++++++++++++------ .../workflow/built-in-workflows.test.ts | 48 ++++++++++++++ 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/packages/daemon/src/lib/space/workflows/built-in-workflows.ts b/packages/daemon/src/lib/space/workflows/built-in-workflows.ts index 0d84ba22e..483b6d244 100644 --- a/packages/daemon/src/lib/space/workflows/built-in-workflows.ts +++ b/packages/daemon/src/lib/space/workflows/built-in-workflows.ts @@ -1910,19 +1910,38 @@ function mergeChannelsFromTemplate( ); if (!existingChannels) return remappedTemplateChannels; - const existingKeys = new Set( - existingChannels.map((channel) => - JSON.stringify({ from: channel.from, to: channel.to, gateId: channel.gateId ?? null }) - ) + const channelKey = (channel: NonNullable[number]) => + JSON.stringify({ from: channel.from, to: channel.to, gateId: channel.gateId ?? null }); + + const templateChannelByKey = new Map( + remappedTemplateChannels.map((channel) => [channelKey(channel), channel]) ); + + // In-place merge of structural channel fields (maxCycles, label) onto + // channels that already exist in the seeded workflow, mirroring the gate + // (writers/features) and node-agent (toolGuards) merges. Channels are + // structural topology: {from, to, gateId} is the stable match key and the + // template owns maxCycles + label. Propagating them keeps structural changes + // (e.g. raising a cyclic cap 6 → 50) landing on pre-existing spaces; without + // this, the unconditional templateHash write would stamp the new hash while + // leaving the old field values in place, then block any future fix from + // reaching them (the matching hash skips the row on every later startup). + const mergedExisting = existingChannels.map((channel) => { + const templateChannel = templateChannelByKey.get(channelKey(channel)); + if (!templateChannel) return channel; + return { + ...channel, + maxCycles: templateChannel.maxCycles, + label: templateChannel.label, + }; + }); + + const mergedExistingKeys = new Set(mergedExisting.map(channelKey)); const missingTemplateChannels = remappedTemplateChannels.filter( - (channel) => - !existingKeys.has( - JSON.stringify({ from: channel.from, to: channel.to, gateId: channel.gateId ?? null }) - ) + (channel) => !mergedExistingKeys.has(channelKey(channel)) ); - return [...existingChannels, ...missingTemplateChannels]; + return [...mergedExisting, ...missingTemplateChannels]; } function remapTemplateHookAgentSlots( @@ -2103,10 +2122,12 @@ export function mergeGateStructuralFieldsFromTemplate( * Gate `features` are copied from matching template gates so data-driven runtime * checks land on pre-existing spaces. Missing template gates are appended. * Existing checks, scripts, and gate topology remain untouched. - * - Missing template channels are appended so newly-added built-in branches become - * reachable on pre-existing spaces. Template hooks are copied from the built-in - * template so hook-based runtime metadata lands during drift re-stamps. Existing - * channels, layout, and node rows are not regenerated. Workflow IDs, node IDs, and persisted node-agent slots + * - Structural channel fields (maxCycles, label) are merged in-place onto channels matched by + * {from, to, gateId}, and missing template channels are appended so newly-added built-in + * branches become reachable on pre-existing spaces. This is how a raised cyclic cap (e.g. + * maxCycles 6 → 50) lands on pre-existing spaces instead of only newly-created ones. Template + * hooks are copied from the built-in template so hook-based runtime metadata lands during + * drift re-stamps. Existing channels, layout, and node rows are not regenerated. Workflow IDs, node IDs, and persisted node-agent slots * are stable identifiers for in-flight runs, so template drift must never * replace node rows. Agent `toolGuards` are updated in-place on existing node * configs instead. @@ -2117,7 +2138,7 @@ const RESTAMP_FIELDS = [ 'templateHash', 'nodes(postApproval + toolGuards in-place + missing template nodes)', 'gates(field writers + features in-place + missing template gates)', - 'channels(missing template channels)', + 'channels(maxCycles + label in-place on matched channels + missing template channels)', 'hooks(template hooks)', ] as const; @@ -2206,8 +2227,15 @@ export function seedBuiltInWorkflows( ); const removedLegacyPrReadyChannels = (existingChannels?.length ?? 0) !== (row.channels?.length ?? 0); - const hasNewTemplateChannels = - (mergedChannels?.length ?? 0) > (existingChannels?.length ?? 0); + // mergeChannelsFromTemplate propagates structural fields (maxCycles, + // label) in-place on matched channels in addition to appending any + // missing template channels. Detect whether the merge changed anything + // — added channels OR updated structural fields — so the result is + // persisted even when the channel count is unchanged (e.g. raising a + // cyclic cap 6 → 50 on an already-seeded workflow). + const channelsChanged = + removedLegacyPrReadyChannels || + JSON.stringify(mergedChannels) !== JSON.stringify(existingChannels); workflowManager.updateWorkflow(row.id, { completionAutonomyLevel: template.completionAutonomyLevel, @@ -2219,9 +2247,7 @@ export function seedBuiltInWorkflows( mergeHooksFromTemplate(template.hooks, template.nodes, migratedNodes, row.hooks) ?? null, nodes: migratedNodes, - ...(hasNewTemplateChannels || removedLegacyPrReadyChannels - ? { channels: mergedChannels } - : {}), + ...(channelsChanged ? { channels: mergedChannels } : {}), templateHash: expectedHash, }); restamped.push(template.name); diff --git a/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts b/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts index 2e1b9c7e6..89c36d624 100644 --- a/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts +++ b/packages/daemon/tests/unit/5-space/workflow/built-in-workflows.test.ts @@ -1818,6 +1818,54 @@ describe('seedBuiltInWorkflows()', () => { expect(after.templateHash).not.toBe('stale-hash-from-a-prior-pr'); }); + test('re-stamp propagates template maxCycles onto existing Fullstack QA Loop cyclic back-channels', () => { + // Seed fresh — Fullstack QA Loop carries the current template (maxCycles: 50) + // and the current template hash. + seedBuiltInWorkflows(SPACE_ID, manager, resolveAgentId); + const wf = manager + .listWorkflows(SPACE_ID) + .find((w) => w.name === FULLSTACK_QA_LOOP_WORKFLOW.name)!; + + // Simulate a pre-fix seed: both cyclic back-channels carry the old maxCycles: 6. + // This is the state any existing space was left in before the 6 → 50 bump. + manager.updateWorkflow(wf.id, { + channels: (wf.channels ?? []).map((ch) => + (ch.from === 'Review' && ch.to === 'Coding') || (ch.from === 'QA' && ch.to === 'Coding') + ? { ...ch, maxCycles: 6 } + : ch + ), + }); + + // Force the re-stamp path by stamping a stale hash (the persisted hash + // matches the OLD template; the new template's hash differs → drift fires). + db.prepare(`UPDATE space_workflows SET template_hash = ? WHERE id = ?`).run( + 'stale-hash-pre-maxCycles-50', + wf.id + ); + + // Sanity-check the simulated drift landed before re-stamp. + const before = manager.getWorkflow(wf.id)!; + expect(before.channels!.find((c) => c.from === 'Review' && c.to === 'Coding')!.maxCycles).toBe( + 6 + ); + expect(before.channels!.find((c) => c.from === 'QA' && c.to === 'Coding')!.maxCycles).toBe(6); + + // Re-run the seeder — re-stamp branch fires. + const result = seedBuiltInWorkflows(SPACE_ID, manager, resolveAgentId); + expect(result.restamped).toContain(FULLSTACK_QA_LOOP_WORKFLOW.name); + + // Structural channel fields propagated in-place: the template's maxCycles: 50 + // now lands on the already-seeded back-channels. Without the in-place merge + // this silently fails — the old maxCycles: 6 is preserved verbatim while the + // hash is bumped to the 50-hash, permanently blocking future fixes from + // reaching existing spaces (the matching hash skips the row on every restart). + const after = manager.getWorkflow(wf.id)!; + expect(after.channels!.find((c) => c.from === 'Review' && c.to === 'Coding')!.maxCycles).toBe( + 50 + ); + expect(after.channels!.find((c) => c.from === 'QA' && c.to === 'Coding')!.maxCycles).toBe(50); + }); + test('re-stamp does NOT touch handles — custom user handle is preserved', () => { seedBuiltInWorkflows(SPACE_ID, manager, resolveAgentId); const coding = manager.listWorkflows(SPACE_ID).find((w) => w.name === CODING_WORKFLOW.name)!; From 5cb7b48a403a342a3f9a8b16053fc77008818f26 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 27 Jul 2026 14:51:30 -0400 Subject: [PATCH 3/3] docs: note built-in channel maxCycles/label are template-managed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the conscious scope decision for the in-place channel merge: built-in channel maxCycles/label are template-managed structural fields, consistent with completionAutonomyLevel, gate writers/features, node toolGuards, and hooks — all unconditionally re-stamped on drift. A user-customized value resets to the template on drift; clone to a custom (non-re-stamped) workflow for a persistent custom cap. Addresses the open review thread on user-customized field preservation. --- .../daemon/src/lib/space/workflows/built-in-workflows.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/lib/space/workflows/built-in-workflows.ts b/packages/daemon/src/lib/space/workflows/built-in-workflows.ts index 483b6d244..cd344c28b 100644 --- a/packages/daemon/src/lib/space/workflows/built-in-workflows.ts +++ b/packages/daemon/src/lib/space/workflows/built-in-workflows.ts @@ -2125,7 +2125,11 @@ export function mergeGateStructuralFieldsFromTemplate( * - Structural channel fields (maxCycles, label) are merged in-place onto channels matched by * {from, to, gateId}, and missing template channels are appended so newly-added built-in * branches become reachable on pre-existing spaces. This is how a raised cyclic cap (e.g. - * maxCycles 6 → 50) lands on pre-existing spaces instead of only newly-created ones. Template + * maxCycles 6 → 50) lands on pre-existing spaces instead of only newly-created ones. Like the + * other template-owned structural fields (completionAutonomyLevel, gate writers/features, node + * toolGuards, hooks), built-in channel maxCycles/label are template-managed: a user-customized + * value (editable via the visual editor) is reset to the template value when drift triggers a + * re-stamp — clone to a custom (non-re-stamped) workflow for a persistent custom cap. Template * hooks are copied from the built-in template so hook-based runtime metadata lands during * drift re-stamps. Existing channels, layout, and node rows are not regenerated. Workflow IDs, node IDs, and persisted node-agent slots * are stable identifiers for in-flight runs, so template drift must never