From 4a72ce66ce9190e84718c1ef474c58388582f3ac Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 20 Oct 2026 19:18:32 +0300 Subject: [PATCH 1/2] Resume broker schema collection when the monitor toggle is switched back on When schema collection stopped, either through the API or the agent's own 24 hour auto-shutdown, the agent paused but was still recorded as running, so switching the monitor toggle on again hit the "already running" branch and never told the agent to start. The toggle looked on but nothing collected. The start handler now always sends the agent a start command when it exists and isn't mid-start or mid-suspend, instead of no-oping when it looks running. The agent treats a repeat start as a reconnect, so this is safe when it was already collecting, and it resumes collection otherwise. --- forge/ee/routes/teamBroker/3rdPartyBroker.js | 11 ++++---- .../routes/teamBroker/3rdPartyBroker_spec.js | 27 +++++++++++++------ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/forge/ee/routes/teamBroker/3rdPartyBroker.js b/forge/ee/routes/teamBroker/3rdPartyBroker.js index e114ae662a..7b0d4cfe34 100644 --- a/forge/ee/routes/teamBroker/3rdPartyBroker.js +++ b/forge/ee/routes/teamBroker/3rdPartyBroker.js @@ -447,10 +447,14 @@ module.exports = async function (app) { } else if (agent?.state === 'starting') { // Another start is already launching the agent: starting is a no-op reply.status(200).send({}) - } else if (agent && agent.state !== 'running') { + } else if (agent) { + // The agent is up (running, or running but no longer collecting after a + // stop or the 24h auto-shutdown). Always (re)send the start command so + // switching the toggle on resumes collection; the agent treats a repeat + // start as a reconnect, so this is safe when it is already collecting. await app.containers.sendBrokerAgentCommand(agent, 'start') reply.status(200).send({}) - } else if (!agent) { + } else { // Stays 'starting' until the driver has launched the agent, so a suspend // arriving meanwhile is refused rather than missing a process it can't stop yet const agent = await app.db.models.TeamBrokerAgent.create({ @@ -467,9 +471,6 @@ module.exports = async function (app) { agent.state = 'running' await agent.save() reply.status(200).send({}) - } else { - // Agent already running: starting is a no-op - reply.status(200).send({}) } } else { if (request.broker.state === 'running') { diff --git a/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js b/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js index 17fd0368fa..20ea5f6f8f 100644 --- a/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js +++ b/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js @@ -781,14 +781,25 @@ describe('3rd Party Broker API', function () { await app.db.models.TeamBrokerAgent.destroy({ where: { TeamId: app.team.id } }) }) - it('start replies when the team-broker agent is already running', async function () { - await app.db.models.TeamBrokerAgent.create({ state: 'running', TeamId: app.team.id }) - const response = await app.inject({ - method: 'POST', - url: `/api/v1/teams/${app.team.hashid}/brokers/team-broker/start`, - cookies: { sid: TestObjects.tokens.bob } - }) - response.statusCode.should.equal(200) + it('start re-sends the start command to an already-running team-broker agent', async function () { + // After a stop or the 24h auto-shutdown the agent stays recorded as running, + // so switching the toggle on must re-issue the start command to resume + // collection rather than treating it as a no-op. + const agent = await app.db.models.TeamBrokerAgent.create({ state: 'running', TeamId: app.team.id }) + const sendBrokerAgentCommand = sinon.stub(app.containers, 'sendBrokerAgentCommand').resolves() + try { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/teams/${app.team.hashid}/brokers/team-broker/start`, + cookies: { sid: TestObjects.tokens.bob } + }) + response.statusCode.should.equal(200) + sendBrokerAgentCommand.calledOnce.should.be.true() + sendBrokerAgentCommand.firstCall.args[0].should.have.property('id', agent.id) + sendBrokerAgentCommand.firstCall.args[1].should.equal('start') + } finally { + sendBrokerAgentCommand.restore() + } }) it('stop replies when the team-broker agent is not running', async function () { From bb509c5b95549eeeda9892a05d3070bea5a96c4c Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 20 Oct 2026 21:33:27 +0300 Subject: [PATCH 2/2] Only restart the broker agent when it isn't already collecting Sending start to an agent that is already collecting tears it down and reconnects, taking the broker offline for that window. Instead of always re-sending start for a running agent, ask the agent for its real state and only send start when it isn't connected. A stopped or auto-shut-down agent resumes; a healthy one stays a no-op. --- forge/ee/routes/teamBroker/3rdPartyBroker.js | 14 +++++---- .../routes/teamBroker/3rdPartyBroker_spec.js | 30 ++++++++++++++++--- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/forge/ee/routes/teamBroker/3rdPartyBroker.js b/forge/ee/routes/teamBroker/3rdPartyBroker.js index 7b0d4cfe34..ac51c68577 100644 --- a/forge/ee/routes/teamBroker/3rdPartyBroker.js +++ b/forge/ee/routes/teamBroker/3rdPartyBroker.js @@ -448,11 +448,15 @@ module.exports = async function (app) { // Another start is already launching the agent: starting is a no-op reply.status(200).send({}) } else if (agent) { - // The agent is up (running, or running but no longer collecting after a - // stop or the 24h auto-shutdown). Always (re)send the start command so - // switching the toggle on resumes collection; the agent treats a repeat - // start as a reconnect, so this is safe when it is already collecting. - await app.containers.sendBrokerAgentCommand(agent, 'start') + // The agent is up. The stored 'running' state doesn't tell us whether + // it's actually collecting, so ask the agent. Only send start when it + // isn't connected (stopped via the API or after the 24h auto-shutdown), + // since start fully tears the agent down and reconnects it, which would + // take a healthy agent offline and drop in-flight messages. + const status = await app.containers.getBrokerAgentState(agent) + if (!status?.connected) { + await app.containers.sendBrokerAgentCommand(agent, 'start') + } reply.status(200).send({}) } else { // Stays 'starting' until the driver has launched the agent, so a suspend diff --git a/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js b/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js index 20ea5f6f8f..826ef6894c 100644 --- a/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js +++ b/test/unit/forge/ee/routes/teamBroker/3rdPartyBroker_spec.js @@ -781,11 +781,12 @@ describe('3rd Party Broker API', function () { await app.db.models.TeamBrokerAgent.destroy({ where: { TeamId: app.team.id } }) }) - it('start re-sends the start command to an already-running team-broker agent', async function () { - // After a stop or the 24h auto-shutdown the agent stays recorded as running, - // so switching the toggle on must re-issue the start command to resume - // collection rather than treating it as a no-op. + it('start resumes a team-broker agent that is up but no longer collecting', async function () { + // After a stop or the 24h auto-shutdown the agent stays recorded as running + // but is no longer connected, so switching the toggle on must re-issue the + // start command to resume collection. const agent = await app.db.models.TeamBrokerAgent.create({ state: 'running', TeamId: app.team.id }) + const getBrokerAgentState = sinon.stub(app.containers, 'getBrokerAgentState').resolves({ connected: false, error: null }) const sendBrokerAgentCommand = sinon.stub(app.containers, 'sendBrokerAgentCommand').resolves() try { const response = await app.inject({ @@ -799,6 +800,27 @@ describe('3rd Party Broker API', function () { sendBrokerAgentCommand.firstCall.args[1].should.equal('start') } finally { sendBrokerAgentCommand.restore() + getBrokerAgentState.restore() + } + }) + + it('start leaves a team-broker agent that is already collecting alone', async function () { + // A connected agent is already collecting; sending it start would tear the + // broker down and reconnect, so this must stay a no-op. + await app.db.models.TeamBrokerAgent.create({ state: 'running', TeamId: app.team.id }) + const getBrokerAgentState = sinon.stub(app.containers, 'getBrokerAgentState').resolves({ connected: true, error: null }) + const sendBrokerAgentCommand = sinon.stub(app.containers, 'sendBrokerAgentCommand').resolves() + try { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/teams/${app.team.hashid}/brokers/team-broker/start`, + cookies: { sid: TestObjects.tokens.bob } + }) + response.statusCode.should.equal(200) + sendBrokerAgentCommand.called.should.be.false() + } finally { + sendBrokerAgentCommand.restore() + getBrokerAgentState.restore() } })