diff --git a/.github/trigger-deploy b/.github/trigger-deploy index c4eeb6ac..2b6b599c 100644 --- a/.github/trigger-deploy +++ b/.github/trigger-deploy @@ -1,3 +1,4 @@ 2026-07-16T10:00:29Z 2026-07-17T16:15:00Z deploy openapi-fallback crawl 2026-07-17T17:20:00Z deploy manifest-merge + alias collapse +2026-07-17T18:00:00Z deploy price tiebreak diff --git a/.github/trigger-test b/.github/trigger-test index dad8065e..1f1d47f9 100644 --- a/.github/trigger-test +++ b/.github/trigger-test @@ -12,3 +12,4 @@ 2026-07-17T16:30:00Z openapi-fallback crawl for manifest-less sellers 2026-07-17T17:05:00Z manifest-path bazaar merge + router alias collapse 2026-07-17T17:45:00Z price tiebreak: known beats unknown +2026-07-18T01:40:00Z wish bridge: qualified-demand gate against single-source bursts diff --git a/.github/workflows/wish-issues.yml b/.github/workflows/wish-issues.yml index 049bbf6a..c8813983 100644 --- a/.github/workflows/wish-issues.yml +++ b/.github/workflows/wish-issues.yml @@ -4,6 +4,12 @@ # GitHub token, so THIS workflow closes the loop: it polls the public # aggregate and opens one issue per qualifying cluster. # +# The gate is the server-computed `qualified` flag, NOT raw count: a cluster +# qualifies only when it clears the count threshold AND shows independence +# (≥2 distinct sources, or demand sustained past the span window). This stops +# a single script minting issues by POSTing the same string 5 times — see +# clusterQualifies in src/wish.js. +# # Dedup is GitHub-side (label `tool-wish` + exact title match), so the server # needs no writable "issueOpened" state and re-runs are idempotent. At most # MAX_NEW issues per run — a spam wave can never flood the tracker. @@ -35,7 +41,10 @@ jobs: # "handled or rejected"; never re-open the same demand automatically). EXISTING=$(gh issue list --repo "$REPO" --label tool-wish --state all --limit 200 --json title --jq '.[].title') CREATED=0 - echo "$WISHES" | jq -c --argjson th "$THRESHOLD" '.clusters[] | select(.count >= $th)' | while read -r c; do + # Gate on the server-computed `qualified` flag (count + independence), + # not raw count. A cached response from before the flag shipped lacks + # the field, so `== true` fails closed and opens nothing — safe. + echo "$WISHES" | jq -c '.clusters[] | select(.qualified == true)' | while read -r c; do [ "$CREATED" -ge "$MAX_NEW" ] && { echo "cap reached ($MAX_NEW) — remaining clusters wait for the next run"; break; } TEXT=$(echo "$c" | jq -r '.text') COUNT=$(echo "$c" | jq -r '.count') @@ -47,7 +56,7 @@ jobs: echo "already tracked: $TITLE" continue fi - BODY=$(printf 'Agents asked for this **%s** times (sources: %s; first seen %s).\n\nNormalized request:\n\n> %s\n\nLive demand board: https://agent402.tools/api/wishes\n\nOpened automatically by the wish-issues bridge when a cluster crosses the signal threshold (%s).' "$COUNT" "$SOURCES" "$FIRST" "$TEXT" "$THRESHOLD") + BODY=$(printf 'Agents asked for this **%s** times (sources: %s; first seen %s).\n\nNormalized request:\n\n> %s\n\nLive demand board: https://agent402.tools/api/wishes\n\nOpened automatically by the wish-issues bridge: this cluster cleared the signal threshold (%s) and qualified as independent demand (multiple sources or sustained over time), not a single-source burst.' "$COUNT" "$SOURCES" "$FIRST" "$TEXT" "$THRESHOLD") gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" --label tool-wish CREATED=$((CREATED+1)) echo "opened: $TITLE" diff --git a/scripts/test-wish.js b/scripts/test-wish.js index 181204fb..40708f79 100644 --- a/scripts/test-wish.js +++ b/scripts/test-wish.js @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { recordWish, getWishesAggregate, WISH_THRESHOLD, + clusterQualifies, QUALIFY_MIN_SPAN_MS, __testSetFilePath, __testSetLineCap, __testState, } from "../src/wish.js"; @@ -107,6 +108,57 @@ function freshFile(tag) { ok(row && row.issueOpened === true, `cluster carries issueOpened:true after crossing the threshold (got ${JSON.stringify(row)})`); } +// --- qualification gate: raw count is necessary but not sufficient --- +// clusterQualifies is the exact predicate the wish-issues workflow selects on. +// Tested directly with synthetic clusters so timestamps are controllable. +{ + const T = 1_700_000_000_000; + const src = (o) => ({ api: 0, mcp: 0, "find-miss": 0, ...o }); + // Below threshold never qualifies, regardless of shape. + ok(!clusterQualifies({ count: WISH_THRESHOLD - 1, sources: src({ api: 2, mcp: 2 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS }), + "below count threshold never qualifies"); + // Single-source short burst (the synthora spam shape) does NOT qualify. + ok(!clusterQualifies({ count: 103, sources: src({ api: 103 }), firstSeen: T, lastSeen: T + 5 * 3_600_000 }), + "single-source burst (103 hits, 5h) does not qualify"); + ok(!clusterQualifies({ count: 18, sources: src({ "find-miss": 18 }), firstSeen: T, lastSeen: T + 5 * 3_600_000 }), + "single-source find-miss burst does not qualify"); + // Corroboration across two sources qualifies at threshold. + ok(clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 3, mcp: 2 }), firstSeen: T, lastSeen: T + 60_000 }), + "two distinct sources qualifies even in a short window"); + // Sustained single-source demand over the span window qualifies. + ok(clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 5 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS }), + "single source sustained past the span window qualifies"); + ok(!clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 5 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS - 1 }), + "single source just under the span window does not qualify"); +} + +// --- qualification, end-to-end through recordWish + getWishesAggregate --- +{ + // A single-source burst to the threshold in one sitting: recorded and + // counted, but the aggregate marks it unqualified so the workflow skips it. + freshFile("qualify-burst"); + for (let i = 0; i < WISH_THRESHOLD; i++) { + recordWish({ need: "synthora mesh 962 m2m services", source: "api", ip: `10.0.9.${i}` }); + } + let agg = getWishesAggregate(); + let row = agg.clusters.find((c) => c.text.includes("synthora")); + ok(row && row.count >= WISH_THRESHOLD && row.qualified === false, + `single-source burst is recorded but unqualified (got ${JSON.stringify(row)})`); + ok(agg.qualifyMinSpanHours === QUALIFY_MIN_SPAN_MS / 3_600_000, "aggregate advertises the span window in hours"); + + // The same count spread across two surfaces qualifies. + freshFile("qualify-multisource"); + recordWish({ need: "real gap tool", source: "api", ip: "10.0.10.1" }); + recordWish({ need: "real gap tool", source: "api", ip: "10.0.10.2" }); + recordWish({ need: "real gap tool", source: "mcp", ip: "10.0.10.3" }); + recordWish({ need: "real gap tool", source: "find-miss", ip: "10.0.10.4" }); + recordWish({ need: "real gap tool", source: "find-miss", ip: "10.0.10.5" }); + agg = getWishesAggregate(); + row = agg.clusters.find((c) => c.text.includes("real gap tool")); + ok(row && row.count === WISH_THRESHOLD && row.qualified === true, + `multi-source demand at threshold qualifies (got ${JSON.stringify(row)})`); +} + // --- file-line cap: accept + count clusters, stop appending raw lines, never crash --- { freshFile("filecap"); @@ -132,7 +184,7 @@ function freshFile(tag) { const agg = getWishesAggregate(); const row = agg.clusters[0]; const keys = Object.keys(row).sort(); - ok(JSON.stringify(keys) === JSON.stringify(["count", "firstSeen", "issueOpened", "lastSeen", "sources", "text"]), `aggregate row has exactly the documented keys, no raw context (got ${keys.join(",")})`); + ok(JSON.stringify(keys) === JSON.stringify(["count", "firstSeen", "issueOpened", "lastSeen", "qualified", "sources", "text"]), `aggregate row has exactly the documented keys, no raw context (got ${keys.join(",")})`); ok(!("context" in row), "aggregate row never carries a raw context field"); ok(row.text.includes("<script>") && !row.text.includes("