From 245415e7c64e5b93d46bdf200b4364398a54157f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Mon, 31 Aug 2026 08:18:59 +0200 Subject: [PATCH] Let check_availability use what the reactive half saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watchlist is read before answering, so a crossing that happened twenty minutes ago is part of the answer. It also fixes a contradiction the agent was publishing: the catalog domain caches reads for 60 seconds, so an agent woken by an event reads a figure taken up to a minute earlier — it answered "68 in stock, above the threshold" and "it crossed the threshold, from 68" in the same sentence. A movement is the catalog's own dated statement; a read is an undated copy. The dated one wins, and the answer says so. Co-Authored-By: Claude Opus 5 --- workflows/README.md | 46 +++++++++++++++++++ .../approvisionnement-check-availability.json | 34 +++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/workflows/README.md b/workflows/README.md index 664ec1f..94dc1f9 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -89,6 +89,52 @@ and through the A2A facade, `escalate: true` becomes a task in state **failed** rather than a completed one — the honest shape for "a human has to look at this". +### It now knows what the reactive half saw + +`check-availability` reads the watchlist before answering, so a crossing that +happened twenty minutes ago is part of the answer: + +``` +"Peut-on promettre S12_1099 ?" + → "No — 1968 Ford Mustang (S12_1099) is down to 8 … It crossed the threshold + at 06:18:24 (driven by orders), from 68." +``` + +"It fell below this afternoon, under demand" is a different decision from "it +has been sitting below for a month", and only the event carries that. + +The watchlist is read over an in-cluster call to the stock-watch workflow's own +webhook — its data lives in that workflow and there is no other way in. The node +is `onError: continueRegularOutput`: an availability answer must not depend on +the watchlist being up. + +### A dated fact beats an undated read + +The two figures can disagree, and when they do the **read** is the wrong one. +This was not theoretical — the agent answered, in one sentence: + +``` +"68 in stock, above the reorder threshold of 50 … It crossed the threshold, from 68." +``` + +The catalog domain caches reads for **60 seconds**. An agent woken by an event +reacts in seconds and then reads a figure taken up to a minute earlier. A +movement is the catalog's own *dated* statement that stock went from X to Y; a +read is an undated copy. So the movement wins, and the answer says the basis +changed: + +``` +Basis: the latest stock movement, which is more recent than the catalog read +``` + +Measured after the fix, with the cache deliberately warmed at 38 and the true +stock moved to 8 inside the window: the answer said **8**. + +This only helps a reference the reactive half is already watching. For any +other, the read stands with whatever staleness it has, and nothing in the +workflow can tell — which is worth remembering before treating any single read +as current. + ## approvisionnement-periodic-reorder The periodic half. A schedule trigger at 06:00, deliberately periodic rather diff --git a/workflows/approvisionnement-check-availability.json b/workflows/approvisionnement-check-availability.json index 46b09d5..c9ed2e4 100644 --- a/workflows/approvisionnement-check-availability.json +++ b/workflows/approvisionnement-check-availability.json @@ -157,7 +157,7 @@ }, { "parameters": { - "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $input.first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\n// Stock on hand only. Quantities already committed on unshipped orders are\n// NOT deducted, and the answer says so rather than implying a certainty it does\n// not have: the sales API cannot be filtered by product code, so working it out\n// would mean paging the whole orderdetails table on every question \u2014 sixty\n// pages, for one answer. See the backlog note in CLAUDE.md.\nconst basis = 'stock on hand; quantities committed on open orders are not deducted';\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}. `\n + `Basis: ${basis}.`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised. `\n + `Basis: ${basis}.`,\n escalate: false,\n}}];\n" + "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $('Read stock').first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nlet stock = Number(product.quantityinstock);\n// What the reactive half noticed, if anything. `product.stock_changed` is\n// consumed as it happens, so a crossing that occurred twenty minutes ago is\n// known here \u2014 and \"it fell below this afternoon, under demand\" is a different\n// decision from \"it has been sitting below for a month\".\n//\n// Read from the watchlist, and absent if the watchlist could not be read: an\n// availability answer must not depend on it.\nlet movement = null;\ntry {\n const watch = $('Recent movement').first()?.json ?? {};\n movement = (watch.entries ?? []).find((e) => e.productcode === product.productcode) ?? null;\n} catch (e) {\n movement = null;\n}\n\n// The two figures can disagree, and when they do the read is the wrong one.\n//\n// A movement is the catalog's own dated statement that stock went from X to Y.\n// A read is an undated copy that may have been taken before that happened \u2014\n// which is how this agent came to answer \"68 in stock, above the threshold\" and\n// \"it crossed the threshold, from 68\" in the same sentence. It was measured:\n// the read lagged the event by up to a minute.\n//\n// So a dated fact beats an undated one. This only helps a reference the\n// reactive half is already watching; for any other, the read stands with\n// whatever staleness it has, and nothing here can tell.\nlet basedOnMovement = false;\nif (movement && Number.isFinite(Number(movement.on_hand)) && Number(movement.on_hand) !== stock) {\n stock = Number(movement.on_hand);\n basedOnMovement = true;\n}\n\nlet context = '';\nif (movement) {\n const when = movement.last_movement_at ?? movement.observed_at;\n const driver = movement.driver === 'demand'\n ? 'driven by orders'\n : 'a stock correction, not demand';\n context = movement.stock_out\n ? ` It ran out at ${when} (${driver}).`\n : ` It crossed the threshold at ${when} (${driver}), from ${movement.was}.`;\n}\n\nconst available = stock >= THRESHOLD;\n\n// Stock on hand only. Quantities already committed on unshipped orders are\n// NOT deducted, and the answer says so rather than implying a certainty it does\n// not have: the sales API cannot be filtered by product code, so working it out\n// would mean paging the whole orderdetails table on every question \u2014 sixty\n// pages, for one answer. See the backlog note in CLAUDE.md.\nconst basis = (basedOnMovement\n ? 'the latest stock movement, which is more recent than the catalog read'\n : 'stock on hand')\n + '; quantities committed on open orders are not deducted';\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}. `\n + `Basis: ${basis}.${context}`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised. `\n + `Basis: ${basis}.${context}`,\n escalate: false,\n}}];\n" }, "id": "answer", "name": "Answer", @@ -182,6 +182,25 @@ 1100, 0 ] + }, + { + "parameters": { + "method": "GET", + "url": "http://n8n.n8n.svc.cluster.local/webhook/stock-watchlist", + "options": { + "timeout": 5000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1100, + 0 + ], + "id": "recent-movement", + "name": "Recent movement", + "onError": "continueRegularOutput", + "alwaysOutputData": true } ], "connections": { @@ -233,7 +252,7 @@ "main": [ [ { - "node": "Answer", + "node": "Recent movement", "type": "main", "index": 0 } @@ -250,6 +269,17 @@ } ] ] + }, + "Recent movement": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": {