@agilemonster/mcp-server 1.0.15 → 1.0.21
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.
- package/README.md +27 -1
- package/bin/mcp.js +0 -0
- package/bin/orchestrator.js +0 -0
- package/bin/worker.js +0 -0
- package/dist/client.d.ts +29 -2
- package/dist/client.js +130 -10
- package/dist/deploy-memory.d.ts +122 -0
- package/dist/deploy-memory.js +192 -0
- package/dist/deploy-profile.d.ts +151 -0
- package/dist/deploy-profile.js +993 -0
- package/dist/deploy-runner.d.ts +144 -0
- package/dist/deploy-runner.js +1108 -0
- package/dist/guidelines.d.ts +1 -1
- package/dist/guidelines.js +6 -0
- package/dist/ide-config.d.ts +44 -0
- package/dist/ide-config.js +250 -0
- package/dist/orchestrator.d.ts +1 -0
- package/dist/orchestrator.js +18 -165
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +58 -0
- package/dist/runtime.d.ts +64 -0
- package/dist/runtime.js +293 -0
- package/dist/server.d.ts +20 -1
- package/dist/server.js +1604 -452
- package/dist/types.d.ts +56 -0
- package/package.json +4 -2
package/dist/server.js
CHANGED
|
@@ -3,10 +3,17 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import
|
|
7
|
-
import { spawn, execSync } from 'node:child_process';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { spawn, execSync, execFileSync } from 'node:child_process';
|
|
8
|
+
import { isUtf8 } from 'node:buffer';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
8
10
|
import { AgileMonsterClient, MCP_SERVER_VERSION } from './client.js';
|
|
9
11
|
import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
|
|
12
|
+
import { knownSecrets, redactResult } from './redact.js';
|
|
13
|
+
import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, } from './deploy-profile.js';
|
|
14
|
+
import { addPendingVerification, getCardState, loadMemory, recordAttempt, removePendingVerification, setCardState, setExplicitProfile, } from './deploy-memory.js';
|
|
15
|
+
import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
|
|
16
|
+
import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
|
|
10
17
|
export function createServer(client = new AgileMonsterClient()) {
|
|
11
18
|
const server = new Server({
|
|
12
19
|
name: 'agilemonster-mcp-server',
|
|
@@ -110,7 +117,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
110
117
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
111
118
|
return { resources };
|
|
112
119
|
});
|
|
113
|
-
|
|
120
|
+
const readResource = async (request) => {
|
|
114
121
|
const { uri } = request.params;
|
|
115
122
|
if (uri === 'agilemonster://guidelines/agents') {
|
|
116
123
|
return {
|
|
@@ -127,7 +134,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
127
134
|
const projectConfig = client.loadProjectConfig();
|
|
128
135
|
const projectName = projectConfig?.board_name || path.basename(process.cwd());
|
|
129
136
|
const boardId = projectConfig?.board_id;
|
|
130
|
-
const context = await client.getContext(projectName, boardId);
|
|
137
|
+
const context = await client.getContext(projectName, boardId, undefined, AgileMonsterClient.detectInstalledAgents());
|
|
131
138
|
return {
|
|
132
139
|
contents: [
|
|
133
140
|
{
|
|
@@ -135,7 +142,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
135
142
|
mimeType: 'application/json',
|
|
136
143
|
text: JSON.stringify({
|
|
137
144
|
config_source: projectConfig ? '.agilemonster.json' : 'auto_matched',
|
|
138
|
-
project_config: projectConfig,
|
|
145
|
+
project_config: projectConfig ? { ...projectConfig, api_key: projectConfig.api_key ? '***' : undefined } : null,
|
|
146
|
+
deploy_profile: currentDeployProfileSummary(client),
|
|
139
147
|
...context,
|
|
140
148
|
}, null, 2),
|
|
141
149
|
},
|
|
@@ -143,7 +151,9 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
143
151
|
};
|
|
144
152
|
}
|
|
145
153
|
throw new Error(`Resource not found: ${uri}`);
|
|
146
|
-
}
|
|
154
|
+
};
|
|
155
|
+
// Every resource passes through one filter, so a credential can never reach an agent's context
|
|
156
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => redactResult(await readResource(request), knownSecrets(client.getApiKey())));
|
|
147
157
|
// -------------------------------------------------------------------------
|
|
148
158
|
// 3. Tools for Autonomous Agents
|
|
149
159
|
// -------------------------------------------------------------------------
|
|
@@ -452,11 +462,61 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
452
462
|
required: ['card_id'],
|
|
453
463
|
},
|
|
454
464
|
},
|
|
465
|
+
{
|
|
466
|
+
name: 'agilemonster_get_deploy_profile',
|
|
467
|
+
description: 'How THIS project is deployed: the route (GitHub Actions, local Docker Compose, deploy script, cloud CLI, manual), the exact command, confidence, the evidence behind it, alternatives found, and recent deployment history on this machine. Call this before deploying or before writing a deployment note, instead of guessing.',
|
|
468
|
+
inputSchema: {
|
|
469
|
+
type: 'object',
|
|
470
|
+
properties: {},
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: 'agilemonster_record_deployment',
|
|
475
|
+
description: 'Record how a deployment of this project actually went, so every future task (including cards started from the Agile Monster web app while no IDE is open) deploys the same proven way. Call it after you deploy successfully, when a deployment fails, or when the user tells you how the project is deployed (set pin=true).',
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
method: {
|
|
480
|
+
type: 'string',
|
|
481
|
+
description: 'One of: github_actions, docker_compose, deploy_script, cloud_cli, git_push, manual, none.',
|
|
482
|
+
},
|
|
483
|
+
command: {
|
|
484
|
+
type: 'string',
|
|
485
|
+
description: 'The exact command that deploys, if any (e.g. "docker compose -f infra/compose.yaml up -d --build", "./deploy/deploy.sh").',
|
|
486
|
+
},
|
|
487
|
+
live_url: {
|
|
488
|
+
type: 'string',
|
|
489
|
+
description: 'URL where the deployed result can be checked (production URL, or http://localhost:PORT for a local stack).',
|
|
490
|
+
},
|
|
491
|
+
success: {
|
|
492
|
+
type: 'boolean',
|
|
493
|
+
description: 'Whether the deployment succeeded (default true).',
|
|
494
|
+
},
|
|
495
|
+
error: {
|
|
496
|
+
type: 'string',
|
|
497
|
+
description: 'What went wrong, when success is false.',
|
|
498
|
+
},
|
|
499
|
+
pin: {
|
|
500
|
+
type: 'boolean',
|
|
501
|
+
description: 'true = the user confirmed this is THE way this project deploys; it then overrides auto-detection until changed.',
|
|
502
|
+
},
|
|
503
|
+
notes: {
|
|
504
|
+
type: 'string',
|
|
505
|
+
description: 'Anything a future agent must know (required env, order of steps, which branch deploys).',
|
|
506
|
+
},
|
|
507
|
+
card_id: {
|
|
508
|
+
type: 'string',
|
|
509
|
+
description: 'The card this deployment belongs to, if any.',
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
required: ['method'],
|
|
513
|
+
},
|
|
514
|
+
},
|
|
455
515
|
];
|
|
456
516
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
457
517
|
return { tools };
|
|
458
518
|
});
|
|
459
|
-
|
|
519
|
+
const callTool = async (request) => {
|
|
460
520
|
const { name, arguments: args = {} } = request.params;
|
|
461
521
|
const projectConfig = client.loadProjectConfig();
|
|
462
522
|
try {
|
|
@@ -466,14 +526,15 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
466
526
|
projectConfig?.board_name ||
|
|
467
527
|
path.basename(process.cwd());
|
|
468
528
|
const boardId = args.board_id || projectConfig?.board_id;
|
|
469
|
-
const context = await client.getContext(projectName, boardId);
|
|
529
|
+
const context = await client.getContext(projectName, boardId, undefined, AgileMonsterClient.detectInstalledAgents());
|
|
470
530
|
return {
|
|
471
531
|
content: [
|
|
472
532
|
{
|
|
473
533
|
type: 'text',
|
|
474
534
|
text: JSON.stringify({
|
|
475
535
|
config_source: projectConfig ? '.agilemonster.json' : 'auto_matched',
|
|
476
|
-
project_config: projectConfig,
|
|
536
|
+
project_config: projectConfig ? { ...projectConfig, api_key: projectConfig.api_key ? '***' : undefined } : null,
|
|
537
|
+
deploy_profile: currentDeployProfileSummary(client),
|
|
477
538
|
...context,
|
|
478
539
|
}, null, 2),
|
|
479
540
|
},
|
|
@@ -486,6 +547,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
486
547
|
const provider = args.provider || projectConfig?.provider || 'antigravity';
|
|
487
548
|
const reviewList = args.review_list || projectConfig?.review_list;
|
|
488
549
|
const savedPath = client.saveProjectConfig({
|
|
550
|
+
// Keep what bootstrap stored (API key, board name, done list ...): this call only re-points the binding
|
|
551
|
+
...(projectConfig || {}),
|
|
489
552
|
workspace_id: workspaceId,
|
|
490
553
|
board_id: boardId,
|
|
491
554
|
provider,
|
|
@@ -564,7 +627,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
564
627
|
content: [
|
|
565
628
|
{
|
|
566
629
|
type: 'text',
|
|
567
|
-
text: `Task claimed successfully on board "${card.board_name || expectedBoardId}". Status is now "in_progress".\n${JSON.stringify(card, null, 2)}`,
|
|
630
|
+
text: `Task claimed successfully on board "${card.board_name || expectedBoardId}". Status is now "in_progress".\n\n${deployGuidanceText(client)}\n\n${JSON.stringify(card, null, 2)}`,
|
|
568
631
|
},
|
|
569
632
|
],
|
|
570
633
|
};
|
|
@@ -688,6 +751,102 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
688
751
|
],
|
|
689
752
|
};
|
|
690
753
|
}
|
|
754
|
+
case 'agilemonster_get_deploy_profile': {
|
|
755
|
+
const root = projectRootFor(client);
|
|
756
|
+
const memory = loadMemory(root);
|
|
757
|
+
const profile = resolveDeployProfile(root, { memory });
|
|
758
|
+
const history = memory.history.slice(-5).map((h) => ({
|
|
759
|
+
at: h.at,
|
|
760
|
+
method: h.method,
|
|
761
|
+
ok: h.ok,
|
|
762
|
+
pending: h.pending || undefined,
|
|
763
|
+
commit: h.commit || undefined,
|
|
764
|
+
error: h.error || undefined,
|
|
765
|
+
}));
|
|
766
|
+
return {
|
|
767
|
+
content: [
|
|
768
|
+
{
|
|
769
|
+
type: 'text',
|
|
770
|
+
text: `${describeProfile(profile)}\n\n` +
|
|
771
|
+
JSON.stringify({
|
|
772
|
+
project_path: root,
|
|
773
|
+
method: profile.method,
|
|
774
|
+
command: profile.command,
|
|
775
|
+
confidence: profile.confidence,
|
|
776
|
+
source: profile.source,
|
|
777
|
+
live_url: profile.liveUrl,
|
|
778
|
+
live_url_source: profile.liveUrlSource,
|
|
779
|
+
build_command: profile.buildCommand,
|
|
780
|
+
alternatives: profile.alternatives.map((a) => ({ method: a.method, command: a.command, score: a.score, evidence: a.evidence })),
|
|
781
|
+
recent_deployments: history,
|
|
782
|
+
notes_from_project_docs: profile.deploymentNotes || undefined,
|
|
783
|
+
pinned_notes: memory.explicit?.notes || undefined,
|
|
784
|
+
}, null, 2) +
|
|
785
|
+
`\n\nAfter you deploy, call agilemonster_record_deployment so the next task (including cards run from the web app) reuses what worked.`,
|
|
786
|
+
},
|
|
787
|
+
],
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
case 'agilemonster_record_deployment': {
|
|
791
|
+
const root = projectRootFor(client);
|
|
792
|
+
const method = normalizeMethod(args.method);
|
|
793
|
+
if (!method) {
|
|
794
|
+
return {
|
|
795
|
+
isError: true,
|
|
796
|
+
content: [
|
|
797
|
+
{
|
|
798
|
+
type: 'text',
|
|
799
|
+
text: `Unknown deployment method "${String(args.method)}". Use one of: github_actions, docker_compose, deploy_script, cloud_cli, git_push, manual, none.`,
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const command = typeof args.command === 'string' && args.command.trim() ? args.command.trim() : null;
|
|
805
|
+
const liveUrl = typeof args.live_url === 'string' && /^https?:\/\//i.test(args.live_url.trim()) ? args.live_url.trim() : null;
|
|
806
|
+
const success = args.success === undefined ? true : Boolean(args.success);
|
|
807
|
+
const pin = Boolean(args.pin);
|
|
808
|
+
const notes = typeof args.notes === 'string' ? args.notes.trim().slice(0, 600) : null;
|
|
809
|
+
const errorText = typeof args.error === 'string' ? args.error.trim().slice(0, 600) : null;
|
|
810
|
+
const cardId = typeof args.card_id === 'string' ? args.card_id : null;
|
|
811
|
+
const provider = projectConfig?.provider || 'agent';
|
|
812
|
+
if (pin) {
|
|
813
|
+
setExplicitProfile(root, { method, command, live_url: liveUrl, notes, set_by: `${provider} (IDE session)` });
|
|
814
|
+
}
|
|
815
|
+
recordAttempt(root, {
|
|
816
|
+
card_id: cardId,
|
|
817
|
+
method,
|
|
818
|
+
command,
|
|
819
|
+
ok: success,
|
|
820
|
+
kind: success ? null : 'reported_failure',
|
|
821
|
+
error: errorText,
|
|
822
|
+
live_url: liveUrl,
|
|
823
|
+
reported_by: provider,
|
|
824
|
+
});
|
|
825
|
+
const synced = await client.reportDeployment({
|
|
826
|
+
card_id: cardId,
|
|
827
|
+
board_id: projectConfig?.board_id || null,
|
|
828
|
+
project_path: root,
|
|
829
|
+
method,
|
|
830
|
+
command,
|
|
831
|
+
live_url: liveUrl,
|
|
832
|
+
ok: success,
|
|
833
|
+
error: errorText,
|
|
834
|
+
error_kind: success ? null : 'reported_failure',
|
|
835
|
+
source: pin ? 'user' : 'verified',
|
|
836
|
+
});
|
|
837
|
+
const profile = resolveDeployProfile(root, { memory: loadMemory(root) });
|
|
838
|
+
return {
|
|
839
|
+
content: [
|
|
840
|
+
{
|
|
841
|
+
type: 'text',
|
|
842
|
+
text: `Recorded ${success ? 'successful' : 'failed'} deployment via ${methodLabel(method)}${command ? ` (\`${command}\`)` : ''}` +
|
|
843
|
+
`${pin ? ' and pinned it as this project\'s deployment route' : ''}.` +
|
|
844
|
+
`${synced ? ' Synced to the Agile Monster board binding.' : ' Saved on this machine (the server did not accept the report; it will still be used locally).'}` +
|
|
845
|
+
`\n\nThe project now resolves to:\n${describeProfile(profile)}`,
|
|
846
|
+
},
|
|
847
|
+
],
|
|
848
|
+
};
|
|
849
|
+
}
|
|
691
850
|
default:
|
|
692
851
|
throw new Error(`Unknown tool: ${name}`);
|
|
693
852
|
}
|
|
@@ -703,10 +862,55 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
703
862
|
],
|
|
704
863
|
};
|
|
705
864
|
}
|
|
706
|
-
}
|
|
865
|
+
};
|
|
866
|
+
// Every tool result (including errors) passes through the same filter as resources
|
|
867
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => redactResult(await callTool(request), knownSecrets(client.getApiKey())));
|
|
707
868
|
return server;
|
|
708
869
|
}
|
|
870
|
+
/** The bound project folder for this MCP session (where .agilemonster.json lives), else the working directory. */
|
|
871
|
+
function projectRootFor(client) {
|
|
872
|
+
const config = client.loadProjectConfig();
|
|
873
|
+
if (config?.project_path && fs.existsSync(config.project_path))
|
|
874
|
+
return config.project_path;
|
|
875
|
+
let dir = process.cwd();
|
|
876
|
+
while (dir !== path.parse(dir).root) {
|
|
877
|
+
if (fs.existsSync(path.join(dir, '.agilemonster.json')))
|
|
878
|
+
return dir;
|
|
879
|
+
dir = path.dirname(dir);
|
|
880
|
+
}
|
|
881
|
+
return process.cwd();
|
|
882
|
+
}
|
|
883
|
+
function currentDeployProfileSummary(client) {
|
|
884
|
+
try {
|
|
885
|
+
const root = projectRootFor(client);
|
|
886
|
+
const profile = resolveDeployProfile(root, { memory: loadMemory(root) });
|
|
887
|
+
return {
|
|
888
|
+
method: profile.method,
|
|
889
|
+
command: profile.command,
|
|
890
|
+
confidence: profile.confidence,
|
|
891
|
+
source: profile.source,
|
|
892
|
+
live_url: profile.liveUrl,
|
|
893
|
+
evidence: profile.evidence.slice(0, 3),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function deployGuidanceText(client) {
|
|
901
|
+
try {
|
|
902
|
+
const root = projectRootFor(client);
|
|
903
|
+
const profile = resolveDeployProfile(root, { memory: loadMemory(root) });
|
|
904
|
+
return (`How this project deploys (from project evidence and past deployments):\n${describeProfile(profile)}\n` +
|
|
905
|
+
`After deploying, call agilemonster_record_deployment so future tasks reuse what worked.`);
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
return 'Call agilemonster_get_deploy_profile to see how this project deploys.';
|
|
909
|
+
}
|
|
910
|
+
}
|
|
709
911
|
function sendDesktopNotification(title, message) {
|
|
912
|
+
if (process.env.AGILEMONSTER_NO_NOTIFY === '1')
|
|
913
|
+
return;
|
|
710
914
|
if (process.platform === 'darwin') {
|
|
711
915
|
try {
|
|
712
916
|
const safeTitle = title.replace(/["\\]/g, '');
|
|
@@ -761,15 +965,32 @@ function syncIdeTaskRules(boardName, queuedCards) {
|
|
|
761
965
|
}
|
|
762
966
|
catch { }
|
|
763
967
|
}
|
|
764
|
-
|
|
968
|
+
/** Work in flight (card execution or deployment). Restarts for updates wait until this drops to zero. */
|
|
969
|
+
let activeWork = 0;
|
|
970
|
+
function exitWhenIdle(reason) {
|
|
971
|
+
const startedWaiting = Date.now();
|
|
972
|
+
const check = () => {
|
|
973
|
+
// Never kill a half-applied edit or a running deployment; give up waiting after 30 minutes.
|
|
974
|
+
if (activeWork === 0 || Date.now() - startedWaiting > 30 * 60 * 1000) {
|
|
975
|
+
log.info('daemon.exit', { reason, waited_ms: Date.now() - startedWaiting });
|
|
976
|
+
process.exit(0);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
check();
|
|
980
|
+
setInterval(check, 3000);
|
|
981
|
+
}
|
|
982
|
+
function triggerBackgroundSelfUpdate(targetVersion) {
|
|
765
983
|
try {
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
984
|
+
// Guard against a restart loop: if we already tried to reach this version recently and are still
|
|
985
|
+
// older (e.g. the daemon runs from a source checkout, or npm has not published it yet), do not retry.
|
|
986
|
+
const attemptFile = path.join(agileMonsterHome(), 'update-attempt.json');
|
|
987
|
+
const target = targetVersion || 'latest';
|
|
988
|
+
const previous = readJson(attemptFile);
|
|
989
|
+
if (previous?.target === target && Date.now() - (previous.at || 0) < 6 * 60 * 60 * 1000) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
writeJsonAtomic(attemptFile, { target, at: Date.now(), from: MCP_SERVER_VERSION });
|
|
993
|
+
// 1. Ensure local .cursor/mcp.json and .mcp.json use @latest so next IDE reload uses latest
|
|
773
994
|
const cwd = process.cwd();
|
|
774
995
|
for (const rel of ['.cursor/mcp.json', '.mcp.json', '.vscode/mcp.json']) {
|
|
775
996
|
const p = path.join(cwd, rel);
|
|
@@ -784,36 +1005,150 @@ function triggerBackgroundSelfUpdate() {
|
|
|
784
1005
|
catch { }
|
|
785
1006
|
}
|
|
786
1007
|
}
|
|
1008
|
+
// 2. Install @agilemonster/mcp-server@latest into global/npx cache, then exit once idle so launchd respawns the daemon
|
|
1009
|
+
const child = spawn('sh', ['-c', 'npm install -g @agilemonster/mcp-server@latest >/dev/null 2>&1 || npx -y @agilemonster/mcp-server@latest --version >/dev/null 2>&1'], {
|
|
1010
|
+
stdio: 'ignore',
|
|
1011
|
+
detached: true,
|
|
1012
|
+
});
|
|
1013
|
+
child.on('exit', () => {
|
|
1014
|
+
appendIdeActivityLog(cwd, {
|
|
1015
|
+
cardId: 'system-update',
|
|
1016
|
+
cardTitle: 'MCP Daemon Auto-Update',
|
|
1017
|
+
agentName: 'Agile Monster Daemon',
|
|
1018
|
+
model: 'system',
|
|
1019
|
+
status: 'updated',
|
|
1020
|
+
summary: `Updated @agilemonster/mcp-server from v${MCP_SERVER_VERSION} to v${target} and restarting the daemon.`,
|
|
1021
|
+
});
|
|
1022
|
+
setTimeout(() => exitWhenIdle('self-update'), 1500).unref();
|
|
1023
|
+
});
|
|
1024
|
+
child.unref();
|
|
1025
|
+
}
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
log.warn('daemon.self_update_failed', { error: errorMessage(err) });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Context handed to the code model. Ordered so the most decisive facts come first (older backends keep
|
|
1032
|
+
* only the first 4,000 characters): how the project ships, then the project's own memory/instructions.
|
|
1033
|
+
*/
|
|
1034
|
+
function readProjectInstructionsContext(projectPath, profile) {
|
|
1035
|
+
const sections = [];
|
|
1036
|
+
const maxTotalChars = 12000;
|
|
1037
|
+
if (profile) {
|
|
1038
|
+
const notes = profile.deploymentNotes ? `\nFrom the project docs:\n${profile.deploymentNotes.slice(0, 700)}` : '';
|
|
1039
|
+
sections.push(`=== How this project builds and deploys ===\n${describeProfile(profile)}${notes}`.slice(0, 1500));
|
|
1040
|
+
}
|
|
1041
|
+
const docCandidates = [
|
|
1042
|
+
['MEMORY.md', 2200],
|
|
1043
|
+
['CLAUDE.md', 2200],
|
|
1044
|
+
['AGENTS.md', 1200],
|
|
1045
|
+
['.cursorrules', 1000],
|
|
1046
|
+
['README.md', 2000],
|
|
1047
|
+
['.github/copilot-instructions.md', 1000],
|
|
1048
|
+
];
|
|
1049
|
+
for (const [docName, cap] of docCandidates) {
|
|
1050
|
+
const docPath = path.join(projectPath, docName);
|
|
1051
|
+
if (fs.existsSync(docPath)) {
|
|
1052
|
+
try {
|
|
1053
|
+
const raw = fs.readFileSync(docPath, 'utf8').trim();
|
|
1054
|
+
if (raw.length > 0) {
|
|
1055
|
+
sections.push(`=== ${docName} ===\n${raw.slice(0, cap)}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
catch { }
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
const cursorRulesDir = path.join(projectPath, '.cursor', 'rules');
|
|
1062
|
+
if (fs.existsSync(cursorRulesDir)) {
|
|
1063
|
+
try {
|
|
1064
|
+
const ruleFiles = fs
|
|
1065
|
+
.readdirSync(cursorRulesDir)
|
|
1066
|
+
.filter((f) => (f.endsWith('.md') || f.endsWith('.mdc')) && f !== 'agilemonster-queue.mdc');
|
|
1067
|
+
for (const rf of ruleFiles.slice(0, 5)) {
|
|
1068
|
+
try {
|
|
1069
|
+
const ruleContent = fs.readFileSync(path.join(cursorRulesDir, rf), 'utf8').trim();
|
|
1070
|
+
if (ruleContent) {
|
|
1071
|
+
sections.push(`=== .cursor/rules/${rf} ===\n${ruleContent.slice(0, 1200)}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
catch { }
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
catch { }
|
|
1078
|
+
}
|
|
1079
|
+
for (const sub of ['apps/web/README.md', 'apps/api/README.md', 'frontend/README.md', 'backend/README.md']) {
|
|
1080
|
+
const subDoc = path.join(projectPath, sub);
|
|
1081
|
+
if (fs.existsSync(subDoc)) {
|
|
1082
|
+
try {
|
|
1083
|
+
const raw = fs.readFileSync(subDoc, 'utf8').trim();
|
|
1084
|
+
if (raw.length > 0) {
|
|
1085
|
+
sections.push(`=== ${sub} ===\n${raw.slice(0, 1200)}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
catch { }
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
1092
|
+
if (fs.existsSync(pkgPath)) {
|
|
1093
|
+
try {
|
|
1094
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
1095
|
+
const scripts = pkg?.scripts ? JSON.stringify(pkg.scripts, null, 2) : '';
|
|
1096
|
+
const workspaces = pkg?.workspaces ? JSON.stringify(pkg.workspaces) : '';
|
|
1097
|
+
if (scripts || workspaces) {
|
|
1098
|
+
sections.push(`=== Project Scripts & Workspaces ===\n${scripts ? `scripts: ${scripts}\n` : ''}${workspaces ? `workspaces: ${workspaces}` : ''}`.slice(0, 1200));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
catch { }
|
|
1102
|
+
}
|
|
1103
|
+
try {
|
|
1104
|
+
const recentCommits = execSync('git log -n 8 --oneline', {
|
|
1105
|
+
cwd: projectPath,
|
|
1106
|
+
encoding: 'utf8',
|
|
1107
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1108
|
+
timeout: 5000,
|
|
1109
|
+
}).trim();
|
|
1110
|
+
if (recentCommits) {
|
|
1111
|
+
sections.push(`=== Recent Git Commits ===\n${recentCommits}`);
|
|
1112
|
+
}
|
|
787
1113
|
}
|
|
788
1114
|
catch { }
|
|
1115
|
+
return sections.join('\n\n').slice(0, maxTotalChars);
|
|
789
1116
|
}
|
|
790
1117
|
const activeAutonomousCards = new Set();
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
'
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1118
|
+
const SOURCE_EXTS = new Set(['.tsx', '.ts', '.jsx', '.js', '.dart', '.php', '.py', '.css', '.scss', '.html', '.vue']);
|
|
1119
|
+
const SKIP_DIRS = new Set([
|
|
1120
|
+
'node_modules',
|
|
1121
|
+
'.git',
|
|
1122
|
+
'dist',
|
|
1123
|
+
'build',
|
|
1124
|
+
'.next',
|
|
1125
|
+
'.nuxt',
|
|
1126
|
+
'.dart_tool',
|
|
1127
|
+
'vendor',
|
|
1128
|
+
'coverage',
|
|
1129
|
+
'archive',
|
|
1130
|
+
'backups',
|
|
1131
|
+
]);
|
|
1132
|
+
/** Every candidate source file in the project: Git's own view when available (honours .gitignore), else a bounded walk. */
|
|
1133
|
+
function listProjectFiles(projectPath) {
|
|
1134
|
+
try {
|
|
1135
|
+
if (fs.existsSync(path.join(projectPath, '.git'))) {
|
|
1136
|
+
const out = execFileSync('git', ['ls-files', '-co', '--exclude-standard'], {
|
|
1137
|
+
cwd: projectPath,
|
|
1138
|
+
encoding: 'utf8',
|
|
1139
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1140
|
+
timeout: 10000,
|
|
1141
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
1142
|
+
});
|
|
1143
|
+
const files = out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
1144
|
+
if (files.length > 0)
|
|
1145
|
+
return files;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
catch { }
|
|
1149
|
+
const found = [];
|
|
815
1150
|
const walk = (dir, depth) => {
|
|
816
|
-
if (depth >
|
|
1151
|
+
if (depth > 8 || found.length > 20000)
|
|
817
1152
|
return;
|
|
818
1153
|
let entries = [];
|
|
819
1154
|
try {
|
|
@@ -823,67 +1158,157 @@ function collectProjectSourceFiles(projectPath, keywords) {
|
|
|
823
1158
|
return;
|
|
824
1159
|
}
|
|
825
1160
|
for (const entry of entries) {
|
|
826
|
-
|
|
827
|
-
if (name.startsWith('.'))
|
|
828
|
-
continue;
|
|
829
|
-
if (/backup|copy|\s\d+/i.test(name))
|
|
1161
|
+
if (entry.name.startsWith('.'))
|
|
830
1162
|
continue;
|
|
831
|
-
const full = path.join(dir, name);
|
|
1163
|
+
const full = path.join(dir, entry.name);
|
|
832
1164
|
if (entry.isDirectory()) {
|
|
833
|
-
if (
|
|
834
|
-
|
|
835
|
-
walk(full, depth + 1);
|
|
1165
|
+
if (!SKIP_DIRS.has(entry.name.toLowerCase()))
|
|
1166
|
+
walk(full, depth + 1);
|
|
836
1167
|
}
|
|
837
1168
|
else if (entry.isFile()) {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1169
|
+
found.push(path.relative(projectPath, full).replace(/\\/g, '/'));
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
walk(projectPath, 0);
|
|
1174
|
+
return found;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Which top-level folders and files the team has actually been working in lately, measured in commits
|
|
1178
|
+
* (not file touches, so one bulk reformat of a legacy folder does not make it look active).
|
|
1179
|
+
*/
|
|
1180
|
+
function recentGitActivity(projectPath) {
|
|
1181
|
+
const files = new Set();
|
|
1182
|
+
const dirCommits = new Map();
|
|
1183
|
+
try {
|
|
1184
|
+
const out = execFileSync('git', ['log', '-n', '150', '--name-only', '--format=__AM_COMMIT__'], {
|
|
1185
|
+
cwd: projectPath,
|
|
1186
|
+
encoding: 'utf8',
|
|
1187
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1188
|
+
timeout: 10000,
|
|
1189
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1190
|
+
});
|
|
1191
|
+
for (const commit of out.split('__AM_COMMIT__')) {
|
|
1192
|
+
const tops = new Set();
|
|
1193
|
+
for (const line of commit.split('\n')) {
|
|
1194
|
+
const rel = line.trim();
|
|
1195
|
+
if (!rel)
|
|
842
1196
|
continue;
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1197
|
+
files.add(rel);
|
|
1198
|
+
if (rel.includes('/'))
|
|
1199
|
+
tops.add(rel.split('/')[0]);
|
|
1200
|
+
}
|
|
1201
|
+
for (const top of tops)
|
|
1202
|
+
dirCommits.set(top, (dirCommits.get(top) || 0) + 1);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
catch { }
|
|
1206
|
+
let maxCommits = 0;
|
|
1207
|
+
for (const n of dirCommits.values())
|
|
1208
|
+
maxCommits = Math.max(maxCommits, n);
|
|
1209
|
+
return { files, dirCommits, maxCommits };
|
|
1210
|
+
}
|
|
1211
|
+
export function collectProjectSourceFiles(projectPath, keywords) {
|
|
1212
|
+
const isFlutterProject = fs.existsSync(path.join(projectPath, 'pubspec.yaml'));
|
|
1213
|
+
const activity = recentGitActivity(projectPath);
|
|
1214
|
+
let docsText = '';
|
|
1215
|
+
for (const doc of ['CLAUDE.md', 'AGENTS.md', 'MEMORY.md', 'README.md']) {
|
|
1216
|
+
try {
|
|
1217
|
+
docsText += fs.readFileSync(path.join(projectPath, doc), 'utf8').slice(0, 20000).toLowerCase() + '\n';
|
|
1218
|
+
}
|
|
1219
|
+
catch { }
|
|
1220
|
+
}
|
|
1221
|
+
const candidates = [];
|
|
1222
|
+
for (const rel of listProjectFiles(projectPath)) {
|
|
1223
|
+
const segments = rel.split('/');
|
|
1224
|
+
const name = segments[segments.length - 1];
|
|
1225
|
+
const ext = path.extname(name).toLowerCase();
|
|
1226
|
+
if (!SOURCE_EXTS.has(ext))
|
|
1227
|
+
continue;
|
|
1228
|
+
if (/\.(test|spec|min|d)\./i.test(name))
|
|
1229
|
+
continue;
|
|
1230
|
+
const dirsOnly = segments.slice(0, -1).map((s) => s.toLowerCase());
|
|
1231
|
+
if (segments.some((s) => s.startsWith('.') || /backup|copy|\s\d+/i.test(s)))
|
|
1232
|
+
continue;
|
|
1233
|
+
if (dirsOnly.some((s) => SKIP_DIRS.has(s) || /blueprint|figma[-_]export|mockup|prototype/i.test(s)))
|
|
1234
|
+
continue;
|
|
1235
|
+
// In a Flutter app, the native platform runner folders are generated scaffolding, not the app's source
|
|
1236
|
+
if (isFlutterProject && ['ios', 'android', 'macos', 'linux', 'windows', 'web'].includes(dirsOnly[0] || ''))
|
|
1237
|
+
continue;
|
|
1238
|
+
const lowerRel = rel.toLowerCase();
|
|
1239
|
+
let score = 0;
|
|
1240
|
+
if (/blueprint|figma-export|figma_export|mockup|prototype|sample|temp/i.test(lowerRel))
|
|
1241
|
+
score -= 50;
|
|
1242
|
+
// Active application directories, including suffixed monorepo folders (backend-php/, frontend-flutter/, …)
|
|
1243
|
+
if (/^(apps|packages|src|lib|app|web|server|api|client|frontend[\w.-]*|backend[\w.-]*)\//i.test(lowerRel))
|
|
1244
|
+
score += 35;
|
|
1245
|
+
const top = dirsOnly[0] || '';
|
|
1246
|
+
if (top) {
|
|
1247
|
+
// Folders the project docs name explicitly are the live ones (`backend-php/` must not count as `backend/`)
|
|
1248
|
+
if (new RegExp(`(^|[^\\w-])${top.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`).test(docsText))
|
|
1249
|
+
score += 15;
|
|
1250
|
+
// Rank top-level folders by how much of the recent history touched them: next to an active
|
|
1251
|
+
// `backend-php/`, a `backend/` that one commit in 150 touched is legacy.
|
|
1252
|
+
if (activity.maxCommits >= 10) {
|
|
1253
|
+
const commits = activity.dirCommits.get(segments[0]) || 0;
|
|
1254
|
+
const share = commits / activity.maxCommits;
|
|
1255
|
+
if (share < 0.06)
|
|
1256
|
+
score -= 30;
|
|
1257
|
+
else
|
|
1258
|
+
score += Math.round(12 * share);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (activity.files.has(rel))
|
|
1262
|
+
score += 8;
|
|
1263
|
+
for (const kw of keywords) {
|
|
1264
|
+
if (kw.length >= 3 && lowerRel.includes(kw))
|
|
1265
|
+
score += 25;
|
|
1266
|
+
}
|
|
1267
|
+
if (ext === '.tsx' || ext === '.jsx' || ext === '.dart' || ext === '.vue') {
|
|
1268
|
+
if (/component|screen|widget|modal|page|view/.test(lowerRel))
|
|
1269
|
+
score += 12;
|
|
1270
|
+
}
|
|
1271
|
+
if (ext === '.php' || ext === '.py' || ext === '.ts') {
|
|
1272
|
+
if (/controller|service|route|model/.test(lowerRel))
|
|
1273
|
+
score += 12;
|
|
1274
|
+
}
|
|
1275
|
+
candidates.push({ rel, score });
|
|
1276
|
+
}
|
|
1277
|
+
// Content keyword hits are the strongest signal but cost a file read: spend them on the best-looking files
|
|
1278
|
+
candidates.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
|
|
1279
|
+
for (const c of candidates.slice(0, 1200)) {
|
|
1280
|
+
const ext = path.extname(c.rel).toLowerCase();
|
|
1281
|
+
if (!['.tsx', '.ts', '.dart', '.php', '.jsx', '.vue', '.py', '.js'].includes(ext))
|
|
1282
|
+
continue;
|
|
1283
|
+
try {
|
|
1284
|
+
const full = path.join(projectPath, c.rel);
|
|
1285
|
+
if (fs.statSync(full).size < 120000) {
|
|
1286
|
+
const contentLower = fs.readFileSync(full, 'utf8').toLowerCase();
|
|
846
1287
|
for (const kw of keywords) {
|
|
847
|
-
if (kw.length >=
|
|
848
|
-
score +=
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
// Check file content for keyword hits on promising source files
|
|
852
|
-
if (ext === '.tsx' || ext === '.ts' || ext === '.dart' || ext === '.php' || ext === '.jsx') {
|
|
853
|
-
try {
|
|
854
|
-
const stat = fs.statSync(full);
|
|
855
|
-
if (stat.size < 120000) {
|
|
856
|
-
const contentLower = fs.readFileSync(full, 'utf8').toLowerCase();
|
|
857
|
-
for (const kw of keywords) {
|
|
858
|
-
if (kw.length >= 4 && contentLower.includes(kw)) {
|
|
859
|
-
score += 10;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
catch { }
|
|
865
|
-
}
|
|
866
|
-
// Bonus score for UI components / screens AND backend controllers / models
|
|
867
|
-
if (ext === '.tsx' || ext === '.jsx' || ext === '.dart' || ext === '.vue') {
|
|
868
|
-
if (lowerRel.includes('component') || lowerRel.includes('screen') || lowerRel.includes('widget') || lowerRel.includes('modal') || lowerRel.includes('model')) {
|
|
869
|
-
score += 8;
|
|
870
|
-
}
|
|
1288
|
+
if (kw.length >= 4 && contentLower.includes(kw))
|
|
1289
|
+
c.score += 10;
|
|
871
1290
|
}
|
|
872
|
-
if (ext === '.php' || ext === '.py' || ext === '.ts') {
|
|
873
|
-
if (lowerRel.includes('controller') || lowerRel.includes('service') || lowerRel.includes('route')) {
|
|
874
|
-
score += 10;
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
results.push({ rel, score });
|
|
878
1291
|
}
|
|
879
1292
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
return
|
|
1293
|
+
catch { }
|
|
1294
|
+
}
|
|
1295
|
+
candidates.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
|
|
1296
|
+
return candidates.slice(0, 300).map((r) => r.rel);
|
|
1297
|
+
}
|
|
1298
|
+
/** Lock files are tiny, but they are written per card forever: drop the ones nobody can still be waiting on. */
|
|
1299
|
+
function pruneStaleLocks(maxAgeMs = 45 * 24 * 60 * 60 * 1000) {
|
|
1300
|
+
try {
|
|
1301
|
+
const dir = path.join(agileMonsterHome(), 'locks');
|
|
1302
|
+
for (const name of fs.readdirSync(dir)) {
|
|
1303
|
+
const file = path.join(dir, name);
|
|
1304
|
+
if (Date.now() - fs.statSync(file).mtimeMs > maxAgeMs)
|
|
1305
|
+
fs.rmSync(file, { force: true });
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
catch { }
|
|
884
1309
|
}
|
|
885
1310
|
function getAgileMonsterLocksDir() {
|
|
886
|
-
const dir = path.join(
|
|
1311
|
+
const dir = path.join(agileMonsterHome(), 'locks');
|
|
887
1312
|
try {
|
|
888
1313
|
if (!fs.existsSync(dir)) {
|
|
889
1314
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -894,9 +1319,8 @@ function getAgileMonsterLocksDir() {
|
|
|
894
1319
|
}
|
|
895
1320
|
/**
|
|
896
1321
|
* Append a live, human-readable IDE activity entry to `<projectPath>/.agilemonster/ACTIVITY.md`
|
|
897
|
-
* (
|
|
898
|
-
*
|
|
899
|
-
* troubleshooting logs, files inspected, build verification output, and diffs.
|
|
1322
|
+
* (kept out of Git via `.git/info/exclude`). Developers can open it in their IDE at any time to see
|
|
1323
|
+
* agent troubleshooting logs, files inspected, build verification output, and diffs.
|
|
900
1324
|
*/
|
|
901
1325
|
function appendIdeActivityLog(projectPath, entry) {
|
|
902
1326
|
try {
|
|
@@ -904,13 +1328,7 @@ function appendIdeActivityLog(projectPath, entry) {
|
|
|
904
1328
|
if (!fs.existsSync(amDir)) {
|
|
905
1329
|
fs.mkdirSync(amDir, { recursive: true });
|
|
906
1330
|
}
|
|
907
|
-
|
|
908
|
-
if (fs.existsSync(gitExclude)) {
|
|
909
|
-
const exContent = fs.readFileSync(gitExclude, 'utf8');
|
|
910
|
-
if (!exContent.includes('.agilemonster/')) {
|
|
911
|
-
fs.appendFileSync(gitExclude, '\n.agilemonster/\n.agilemonster.json\n', 'utf8');
|
|
912
|
-
}
|
|
913
|
-
}
|
|
1331
|
+
ensureLocalGitExcludes(projectPath);
|
|
914
1332
|
const logPath = path.join(amDir, 'ACTIVITY.md');
|
|
915
1333
|
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
916
1334
|
const header = !fs.existsSync(logPath)
|
|
@@ -933,49 +1351,444 @@ function appendIdeActivityLog(projectPath, entry) {
|
|
|
933
1351
|
}
|
|
934
1352
|
catch { }
|
|
935
1353
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1354
|
+
function boundProfileOf(card) {
|
|
1355
|
+
return {
|
|
1356
|
+
deploy_method: card.deploy_method,
|
|
1357
|
+
deploy_command: card.deploy_command,
|
|
1358
|
+
live_url: card.live_url,
|
|
1359
|
+
build_command: card.build_command,
|
|
1360
|
+
deploy_source: card.deploy_source,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
/** Feed the outcome back to the server so the board's binding converges on the route that really works. */
|
|
1364
|
+
async function reportOutcome(client, card, projectPath, res) {
|
|
1365
|
+
if (res.method === 'none' || res.errorKind === 'needs_confirmation')
|
|
1366
|
+
return;
|
|
1367
|
+
await client.reportDeployment({
|
|
1368
|
+
card_id: card.id,
|
|
1369
|
+
board_id: card.board_id,
|
|
1370
|
+
project_path: projectPath,
|
|
1371
|
+
method: res.method,
|
|
1372
|
+
command: res.command,
|
|
1373
|
+
live_url: res.profile.liveUrlSource === 'guessed' ? null : res.profile.liveUrl,
|
|
1374
|
+
ok: res.ok,
|
|
1375
|
+
// "Pushed but not verified" must not be recorded as a proven route
|
|
1376
|
+
pending: res.pending || res.unverified,
|
|
1377
|
+
error: res.ok ? null : res.errorDetail.slice(0, 480),
|
|
1378
|
+
error_kind: res.errorKind,
|
|
1379
|
+
commit: res.commitHash || null,
|
|
1380
|
+
source: res.profile.source === 'explicit' || res.profile.source === 'user' ? 'user' : 'verified',
|
|
1381
|
+
evidence: res.profile.evidence.slice(0, 3),
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
const sha1 = (text) => crypto.createHash('sha1').update(text).digest('hex');
|
|
1385
|
+
function journalFile(cardId) {
|
|
1386
|
+
return path.join(agileMonsterHome(), 'journal', `${cardId.replace(/[^a-zA-Z0-9-]/g, '')}.json`);
|
|
1387
|
+
}
|
|
1388
|
+
/** Record the pre-edit contents of every file touched so far (called after each write). */
|
|
1389
|
+
function journalEdits(cardId, projectPath, originals) {
|
|
943
1390
|
try {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
let
|
|
1391
|
+
const files = {};
|
|
1392
|
+
for (const [abs, original] of originals.entries()) {
|
|
1393
|
+
let current = original;
|
|
947
1394
|
try {
|
|
948
|
-
|
|
1395
|
+
current = fs.readFileSync(abs, 'utf8');
|
|
949
1396
|
}
|
|
950
1397
|
catch { }
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1398
|
+
files[abs] = { original, edited_sha: sha1(current) };
|
|
1399
|
+
}
|
|
1400
|
+
writeJsonAtomic(journalFile(cardId), { card_id: cardId, project_path: projectPath, pid: process.pid, at: new Date().toISOString(), files });
|
|
1401
|
+
}
|
|
1402
|
+
catch (err) {
|
|
1403
|
+
log.warn('journal.write_failed', { card: cardId, error: errorMessage(err) });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function clearEditJournal(cardId) {
|
|
1407
|
+
try {
|
|
1408
|
+
fs.rmSync(journalFile(cardId), { force: true });
|
|
1409
|
+
}
|
|
1410
|
+
catch { }
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Roll back edits left behind by a worker that died mid-card. A file is only restored if it still holds
|
|
1414
|
+
* exactly what that worker wrote — anything the developer touched since is left alone.
|
|
1415
|
+
*/
|
|
1416
|
+
export function recoverEditJournals() {
|
|
1417
|
+
let restored = 0;
|
|
1418
|
+
try {
|
|
1419
|
+
const dir = path.join(agileMonsterHome(), 'journal');
|
|
1420
|
+
if (!fs.existsSync(dir))
|
|
1421
|
+
return 0;
|
|
1422
|
+
for (const name of fs.readdirSync(dir)) {
|
|
1423
|
+
if (!name.endsWith('.json'))
|
|
1424
|
+
continue;
|
|
1425
|
+
const file = path.join(dir, name);
|
|
1426
|
+
const journal = readJson(file);
|
|
1427
|
+
if (!journal?.files) {
|
|
1428
|
+
fs.rmSync(file, { force: true });
|
|
1429
|
+
continue;
|
|
957
1430
|
}
|
|
958
|
-
if (
|
|
959
|
-
|
|
1431
|
+
if (journal.pid === process.pid || (journal.pid && isPidAlive(journal.pid)))
|
|
1432
|
+
continue; // still being worked on
|
|
1433
|
+
for (const [abs, entry] of Object.entries(journal.files)) {
|
|
1434
|
+
try {
|
|
1435
|
+
if (sha1(fs.readFileSync(abs, 'utf8')) === entry.edited_sha) {
|
|
1436
|
+
fs.writeFileSync(abs, entry.original, 'utf8');
|
|
1437
|
+
restored++;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
catch { }
|
|
960
1441
|
}
|
|
1442
|
+
log.warn('journal.recovered', { card: journal.card_id, project: journal.project_path, files: Object.keys(journal.files).length });
|
|
1443
|
+
fs.rmSync(file, { force: true });
|
|
961
1444
|
}
|
|
962
|
-
fs.writeFileSync(lockFile, JSON.stringify({
|
|
963
|
-
card_id: card.id,
|
|
964
|
-
card_updated_at: cardUpdatedAt,
|
|
965
|
-
status: 'running',
|
|
966
|
-
started_at: new Date().toISOString(),
|
|
967
|
-
}), 'utf8');
|
|
968
1445
|
}
|
|
969
|
-
catch {
|
|
1446
|
+
catch (err) {
|
|
1447
|
+
log.warn('journal.recover_failed', { error: errorMessage(err) });
|
|
1448
|
+
}
|
|
1449
|
+
return restored;
|
|
1450
|
+
}
|
|
1451
|
+
// ---------------------------------------------------------------------------
|
|
1452
|
+
// Who may steer deployments on this machine
|
|
1453
|
+
// ---------------------------------------------------------------------------
|
|
1454
|
+
const ownerIds = new WeakMap();
|
|
1455
|
+
/** The account whose API key this machine runs under. Only that person may choose or confirm what gets executed here. */
|
|
1456
|
+
function machineOwnerId(client) {
|
|
1457
|
+
let cached = ownerIds.get(client);
|
|
1458
|
+
if (!cached) {
|
|
1459
|
+
cached = client
|
|
1460
|
+
.me()
|
|
1461
|
+
.then((me) => (me?.id ? String(me.id) : null))
|
|
1462
|
+
.catch(() => null);
|
|
1463
|
+
ownerIds.set(client, cached);
|
|
1464
|
+
// Do not cache a failed lookup forever
|
|
1465
|
+
void cached.then((id) => {
|
|
1466
|
+
if (!id)
|
|
1467
|
+
ownerIds.delete(client);
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
return cached;
|
|
1471
|
+
}
|
|
1472
|
+
async function withRetry(what, fn, attempts = 3) {
|
|
1473
|
+
let lastError;
|
|
1474
|
+
for (let i = 0; i < attempts; i++) {
|
|
1475
|
+
try {
|
|
1476
|
+
return await fn();
|
|
1477
|
+
}
|
|
1478
|
+
catch (err) {
|
|
1479
|
+
lastError = err;
|
|
1480
|
+
log.warn('api.retry', { what, attempt: i + 1, error: errorMessage(err) });
|
|
1481
|
+
await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
throw lastError;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* A card whose TITLE is an operations request ("Restart the container", "Redeploy production").
|
|
1488
|
+
* Anchored to the start of the title so "Add a restart button to the server page" is not one.
|
|
1489
|
+
*/
|
|
1490
|
+
export function isRedeployOnlyCard(card) {
|
|
1491
|
+
return /^\s*(please\s+)?(restart|re-?deploy|rebuild|recreate|re-?start)\b[\s\S]{0,60}\b(containers?|docker|server|stack|prod(uction)?|app|site|services?)\b/i.test(card.title || '');
|
|
1492
|
+
}
|
|
1493
|
+
async function runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile) {
|
|
1494
|
+
await client.addProgress(card.id, provider, `This card asks for a deployment, not a code change. Redeploying \`${projectPath}\` now...`);
|
|
1495
|
+
const res = await runDeployment({
|
|
1496
|
+
projectPath,
|
|
1497
|
+
title: card.title || 'redeploy',
|
|
1498
|
+
cardId: card.id,
|
|
1499
|
+
redeploy: true,
|
|
1500
|
+
bound: boundProfileOf(card),
|
|
1501
|
+
});
|
|
1502
|
+
await reportOutcome(client, card, projectPath, res);
|
|
1503
|
+
const blankState = (extra) => ({
|
|
1504
|
+
stage: 'deploy_failed',
|
|
1505
|
+
files: [],
|
|
1506
|
+
summary: `Redeployed for "${card.title}".`,
|
|
1507
|
+
verification_steps: '1. Check that the application is up and serving the latest version.',
|
|
1508
|
+
diff: '',
|
|
1509
|
+
commit: res.commitHash || null,
|
|
1510
|
+
at: new Date().toISOString(),
|
|
1511
|
+
attempts: 1,
|
|
1512
|
+
...extra,
|
|
1513
|
+
});
|
|
1514
|
+
if (!res.ok) {
|
|
1515
|
+
setCardState(projectPath, card.id, blankState({
|
|
1516
|
+
last_error: res.errorDetail,
|
|
1517
|
+
pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
|
|
1518
|
+
}));
|
|
1519
|
+
removeTaskLock(lockFile);
|
|
1520
|
+
await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' ? describeFailureForHuman(res) : `The redeploy did not go through.\n\n${describeFailureForHuman(res)}`);
|
|
970
1521
|
return false;
|
|
971
1522
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1523
|
+
if (res.pending && res.pendingVerification)
|
|
1524
|
+
addPendingVerification(projectPath, { ...res.pendingVerification, provider });
|
|
1525
|
+
setCardState(projectPath, card.id, null);
|
|
1526
|
+
appendIdeActivityLog(projectPath, {
|
|
1527
|
+
cardId: card.id,
|
|
1528
|
+
cardTitle: card.title,
|
|
1529
|
+
agentName: workerLabel,
|
|
1530
|
+
model: card.agent_model || 'auto',
|
|
1531
|
+
status: 'review',
|
|
1532
|
+
summary: res.deployedNote,
|
|
1533
|
+
});
|
|
1534
|
+
await client.submitReview(card.id, provider, res.deployedNote, '1. Check that the application is up and serving the latest version.', undefined, undefined, {
|
|
1535
|
+
logText: `Redeploy for "${card.title}" in ${projectPath}\n${res.deployedNote}`,
|
|
1536
|
+
});
|
|
1537
|
+
writeTaskLock(lockFile, card, 'completed');
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
function latestHumanComment(comments) {
|
|
1541
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
1542
|
+
const c = comments[i];
|
|
1543
|
+
const text = (c.text || '').trim();
|
|
1544
|
+
if (!text)
|
|
1545
|
+
continue;
|
|
1546
|
+
if ((c.author_kind || 'member').toLowerCase() === 'agent')
|
|
1547
|
+
continue;
|
|
1548
|
+
if (/^(🤖|🛠️|✅|🚀|🔄|🛡️|🚨|⚠️|❓|⏳|📦)/u.test(text))
|
|
1549
|
+
continue;
|
|
1550
|
+
return c;
|
|
1551
|
+
}
|
|
1552
|
+
return null;
|
|
1553
|
+
}
|
|
1554
|
+
/** Server timestamps are UTC, with or without a zone suffix. Unknown formats count as "after" (fail open). */
|
|
1555
|
+
function commentIsAfter(comment, isoTime) {
|
|
1556
|
+
const raw = (comment.created_at || '').trim();
|
|
1557
|
+
if (!raw)
|
|
1558
|
+
return true;
|
|
1559
|
+
const normalized = /[zZ]|[+-]\d\d:?\d\d$/.test(raw) ? raw : `${raw.replace(' ', 'T')}Z`;
|
|
1560
|
+
const at = Date.parse(normalized);
|
|
1561
|
+
const ref = Date.parse(isoTime);
|
|
1562
|
+
if (Number.isNaN(at) || Number.isNaN(ref))
|
|
1563
|
+
return true;
|
|
1564
|
+
return at >= ref - 2 * 60 * 1000; // tolerate clock skew between this machine and the server
|
|
1565
|
+
}
|
|
1566
|
+
function writeTaskLock(lockFile, card, status) {
|
|
1567
|
+
try {
|
|
1568
|
+
fs.writeFileSync(lockFile, JSON.stringify({
|
|
1569
|
+
card_id: card.id,
|
|
1570
|
+
card_updated_at: card.updated_at || '',
|
|
1571
|
+
status,
|
|
1572
|
+
[status === 'running' ? 'started_at' : 'completed_at']: new Date().toISOString(),
|
|
1573
|
+
}), 'utf8');
|
|
1574
|
+
}
|
|
1575
|
+
catch { }
|
|
1576
|
+
}
|
|
1577
|
+
function removeTaskLock(lockFile) {
|
|
1578
|
+
try {
|
|
1579
|
+
if (fs.existsSync(lockFile))
|
|
1580
|
+
fs.unlinkSync(lockFile);
|
|
1581
|
+
}
|
|
1582
|
+
catch { }
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* The code change for this card already exists — only its deployment failed. When the human replies
|
|
1586
|
+
* ("retry", or "deploy: <method or command>"), redo ONLY the deployment. Previously the whole card was
|
|
1587
|
+
* re-run from scratch, the model found nothing left to change, and the deploy was never retried.
|
|
1588
|
+
*
|
|
1589
|
+
* Returns 'continue' when the reply is new code feedback and the normal flow should run.
|
|
1590
|
+
*/
|
|
1591
|
+
async function resumeFailedDeployment(client, card, projectPath, provider, workerLabel, state, lockFile) {
|
|
1592
|
+
let human = null;
|
|
1593
|
+
try {
|
|
1594
|
+
human = latestHumanComment(await client.listComments(card.id));
|
|
1595
|
+
}
|
|
1596
|
+
catch (err) {
|
|
1597
|
+
log.warn('card.comments_unavailable', { card: card.id, error: errorMessage(err) });
|
|
1598
|
+
}
|
|
1599
|
+
// Only a reply written AFTER the failure/question counts. An older comment is never re-applied as an instruction.
|
|
1600
|
+
const reply = human && commentIsAfter(human, state.at) ? human : null;
|
|
1601
|
+
const parsed = reply ? parseDeployDirective(reply.text) : null;
|
|
1602
|
+
// A fresh reply that is not about deploying is new feedback on the code: run the full flow.
|
|
1603
|
+
if (reply && !parsed)
|
|
1604
|
+
return 'continue';
|
|
1605
|
+
const pendingRoute = state.pending_confirmation || null;
|
|
1606
|
+
const choosesRoute = Boolean(parsed && (parsed.method || parsed.liveUrl || pendingRoute));
|
|
1607
|
+
if (choosesRoute) {
|
|
1608
|
+
// Choosing or confirming a route decides what is executed on THIS computer, so it has to come from the
|
|
1609
|
+
// person whose account this machine runs under — not from anyone who can comment on the board.
|
|
1610
|
+
const ownerId = await machineOwnerId(client);
|
|
1611
|
+
const authorId = reply?.user_id ? String(reply.user_id) : '';
|
|
1612
|
+
if (!ownerId || !authorId || authorId !== ownerId) {
|
|
1613
|
+
removeTaskLock(lockFile);
|
|
1614
|
+
setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
|
|
1615
|
+
await client.askQuestion(card.id, provider, 'I can only take deployment instructions (which route or command to run) from the person whose computer runs this agent. Please ask them to reply here with `deploy: <method or command>`, or to confirm with **yes**.');
|
|
1616
|
+
return 'failed';
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (pendingRoute && !parsed) {
|
|
1620
|
+
// Re-queued without an answer (card dragged, reassigned, comments unreadable): asking is still pending.
|
|
1621
|
+
// Nothing is ever confirmed on the human's behalf.
|
|
1622
|
+
removeTaskLock(lockFile);
|
|
1623
|
+
setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
|
|
1624
|
+
await client.askQuestion(card.id, provider, `I still need an answer before I deploy this project for the first time. Reply \`deploy: ${pendingRoute.command || pendingRoute.method}\` to confirm, \`deploy: manual\` if you deploy it yourself, or tell me the right route.`);
|
|
1625
|
+
return 'failed';
|
|
1626
|
+
}
|
|
1627
|
+
// No fresh reply at all: a re-queue of a deploy-failed card means "try the same deployment again".
|
|
1628
|
+
let directive = parsed || { retry: true, method: null, command: null, liveUrl: null };
|
|
1629
|
+
// "yes" / "retry" from the owner in answer to "may I deploy with X?" confirms X
|
|
1630
|
+
if (parsed && !directive.method && pendingRoute) {
|
|
1631
|
+
const confirmed = normalizeMethod(pendingRoute.method);
|
|
1632
|
+
if (confirmed)
|
|
1633
|
+
directive = { ...directive, method: confirmed, command: pendingRoute.command };
|
|
1634
|
+
}
|
|
1635
|
+
if (directive.method) {
|
|
1636
|
+
setExplicitProfile(projectPath, {
|
|
1637
|
+
method: directive.method,
|
|
1638
|
+
command: directive.command,
|
|
1639
|
+
live_url: directive.liveUrl,
|
|
1640
|
+
notes: `confirmed on card "${card.title}"`,
|
|
1641
|
+
set_by: 'the machine owner',
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
await client.addProgress(card.id, provider, directive.method
|
|
1645
|
+
? `Got it — deploying with **${methodLabel(directive.method)}**${directive.command ? ` (\`${directive.command}\`)` : ''}. I've saved this as the deployment route for this project, so future cards use it automatically.`
|
|
1646
|
+
: 'Retrying the deployment of the change I already made (no code is being regenerated).');
|
|
1647
|
+
const res = await runDeployment({
|
|
1648
|
+
projectPath,
|
|
1649
|
+
title: card.title || 'update',
|
|
1650
|
+
cardId: card.id,
|
|
1651
|
+
files: state.files,
|
|
1652
|
+
redeploy: true,
|
|
1653
|
+
bound: boundProfileOf(card),
|
|
1654
|
+
override: directive.method ? { method: directive.method, command: directive.command } : null,
|
|
1655
|
+
});
|
|
1656
|
+
await reportOutcome(client, card, projectPath, res);
|
|
1657
|
+
if (!res.ok) {
|
|
1658
|
+
setCardState(projectPath, card.id, {
|
|
1659
|
+
...state,
|
|
1660
|
+
attempts: state.attempts + 1,
|
|
1661
|
+
last_error: res.errorDetail,
|
|
1662
|
+
at: new Date().toISOString(),
|
|
1663
|
+
pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
|
|
1664
|
+
});
|
|
1665
|
+
removeTaskLock(lockFile);
|
|
1666
|
+
appendIdeActivityLog(projectPath, {
|
|
1667
|
+
cardId: card.id,
|
|
1668
|
+
cardTitle: card.title,
|
|
1669
|
+
agentName: workerLabel,
|
|
1670
|
+
model: card.agent_model || 'auto',
|
|
1671
|
+
status: 'deploy_failed',
|
|
1672
|
+
summary: `Deployment retry failed: ${res.errorDetail.slice(0, 500)}`,
|
|
1673
|
+
files: state.files,
|
|
1674
|
+
});
|
|
1675
|
+
await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation'
|
|
1676
|
+
? describeFailureForHuman(res)
|
|
1677
|
+
: `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`);
|
|
1678
|
+
return 'failed';
|
|
1679
|
+
}
|
|
1680
|
+
if (res.pending && res.pendingVerification) {
|
|
1681
|
+
addPendingVerification(projectPath, { ...res.pendingVerification, provider });
|
|
1682
|
+
}
|
|
1683
|
+
setCardState(projectPath, card.id, null);
|
|
1684
|
+
const summary = `${state.summary}\n\n${res.deployedNote}`.trim();
|
|
1685
|
+
appendIdeActivityLog(projectPath, {
|
|
1686
|
+
cardId: card.id,
|
|
1687
|
+
cardTitle: card.title,
|
|
1688
|
+
agentName: workerLabel,
|
|
1689
|
+
model: card.agent_model || 'auto',
|
|
1690
|
+
status: 'review',
|
|
1691
|
+
summary,
|
|
1692
|
+
files: state.files,
|
|
1693
|
+
});
|
|
1694
|
+
await client.submitReview(card.id, provider, summary, state.verification_steps, undefined, undefined, {
|
|
1695
|
+
diffText: state.diff,
|
|
1696
|
+
logText: `Deployment retried for "${card.title}" in ${projectPath}\n${res.deployedNote}`,
|
|
1697
|
+
filesModified: state.files.join(', '),
|
|
1698
|
+
});
|
|
1699
|
+
writeTaskLock(lockFile, card, 'completed');
|
|
1700
|
+
sendDesktopNotification('Agile Monster -> Ready for Review', `Deployed "${card.title}" and moved it to Testing / Review.`);
|
|
1701
|
+
return 'done';
|
|
1702
|
+
}
|
|
1703
|
+
/** Find the nearest package.json (walking up from each changed front-end file) that has a build script and installed deps. */
|
|
1704
|
+
function frontendBuildDirs(projectPath, modifiedFiles) {
|
|
1705
|
+
const root = path.resolve(projectPath);
|
|
1706
|
+
const dirs = [];
|
|
1707
|
+
for (const rel of modifiedFiles) {
|
|
1708
|
+
if (!/\.(tsx?|jsx?|vue|css|scss)$/i.test(rel))
|
|
1709
|
+
continue;
|
|
1710
|
+
let dir = path.dirname(path.resolve(root, rel));
|
|
1711
|
+
while (dir.startsWith(root)) {
|
|
1712
|
+
const pkgJson = path.join(dir, 'package.json');
|
|
1713
|
+
if (fs.existsSync(pkgJson)) {
|
|
1714
|
+
try {
|
|
1715
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
|
|
1716
|
+
if (pkg?.scripts?.build && fs.existsSync(path.join(dir, 'node_modules')) && !dirs.includes(dir)) {
|
|
1717
|
+
dirs.push(dir);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
catch { }
|
|
1721
|
+
break;
|
|
1722
|
+
}
|
|
1723
|
+
if (dir === root)
|
|
1724
|
+
break;
|
|
1725
|
+
dir = path.dirname(dir);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return dirs;
|
|
1729
|
+
}
|
|
1730
|
+
export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
1731
|
+
if (!projectPath || !fs.existsSync(projectPath))
|
|
1732
|
+
return false;
|
|
1733
|
+
if (activeAutonomousCards.has(card.id))
|
|
1734
|
+
return false;
|
|
1735
|
+
const lockFile = path.join(getAgileMonsterLocksDir(), `task-${card.id.slice(0, 8)}.lock`);
|
|
1736
|
+
const cardUpdatedAt = card.updated_at || '';
|
|
1737
|
+
try {
|
|
1738
|
+
if (fs.existsSync(lockFile)) {
|
|
1739
|
+
const stat = fs.statSync(lockFile);
|
|
1740
|
+
let existingLock = {};
|
|
1741
|
+
try {
|
|
1742
|
+
existingLock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
|
1743
|
+
}
|
|
1744
|
+
catch { }
|
|
1745
|
+
const sameRevision = cardUpdatedAt !== '' && existingLock?.card_updated_at === cardUpdatedAt;
|
|
1746
|
+
if (existingLock?.status === 'running' && Date.now() - stat.mtimeMs < 3 * 60 * 1000 && sameRevision) {
|
|
1747
|
+
return false; // Currently running on this exact revision
|
|
1748
|
+
}
|
|
1749
|
+
if (existingLock?.status === 'completed' && sameRevision && Date.now() - stat.mtimeMs < 10 * 60 * 1000) {
|
|
1750
|
+
return false; // Already completed this exact revision
|
|
1751
|
+
}
|
|
1752
|
+
if (!cardUpdatedAt && Date.now() - stat.mtimeMs < 3 * 60 * 1000) {
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
writeTaskLock(lockFile, card, 'running');
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
activeAutonomousCards.add(card.id);
|
|
1762
|
+
activeWork++;
|
|
1763
|
+
const provider = card.agent_provider || 'cursor';
|
|
1764
|
+
const workerLabel = card.agent_display_name || provider;
|
|
1765
|
+
// Original contents of every file this run touches, so an interrupted run never leaves half-applied edits behind
|
|
1766
|
+
const originalContents = new Map();
|
|
1767
|
+
let editsHandedOff = false;
|
|
1768
|
+
log.info('card.start', { card: card.id, title: card.title, project: projectPath, provider });
|
|
1769
|
+
try {
|
|
976
1770
|
// 1. Claim the card (automatically moves it from ToDo -> In Progress on the board!)
|
|
977
1771
|
await client.claimTask(card.id, provider, `auto-pulse-${Date.now()}`, card.board_id);
|
|
978
1772
|
sendDesktopNotification(`Agile Monster -> ${workerLabel}`, `Claimed & moved to In Progress: "${card.title}"`);
|
|
1773
|
+
// 1b. Deploy-only resume: the change exists, only shipping it is outstanding
|
|
1774
|
+
const priorState = getCardState(projectPath, card.id);
|
|
1775
|
+
let carryOverFiles = [];
|
|
1776
|
+
if (priorState?.stage === 'deploy_failed') {
|
|
1777
|
+
const outcome = await resumeFailedDeployment(client, card, projectPath, provider, workerLabel, priorState, lockFile);
|
|
1778
|
+
if (outcome !== 'continue')
|
|
1779
|
+
return outcome === 'done';
|
|
1780
|
+
// The human sent new code feedback instead: keep the earlier files in the commit set and run normally
|
|
1781
|
+
carryOverFiles = priorState.files;
|
|
1782
|
+
}
|
|
1783
|
+
else if (priorState) {
|
|
1784
|
+
// Rework of a change that is still waiting for approval: the earlier files must ship together with the new ones
|
|
1785
|
+
carryOverFiles = priorState.files;
|
|
1786
|
+
}
|
|
1787
|
+
// "Restart the container" / "Redeploy production" cards ask for a deployment, not for a code change
|
|
1788
|
+
if (isRedeployOnlyCard(card)) {
|
|
1789
|
+
return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
|
|
1790
|
+
}
|
|
1791
|
+
const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory: loadMemory(projectPath) });
|
|
979
1792
|
await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...`);
|
|
980
1793
|
// 2. Build keyword list from card title + description and collect project source files
|
|
981
1794
|
const rawText = `${card.title || ''} ${card.description || ''}`.toLowerCase();
|
|
@@ -985,16 +1798,22 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
985
1798
|
.filter((w) => w.length >= 3 && !stopWords.has(w))));
|
|
986
1799
|
const fileIndex = collectProjectSourceFiles(projectPath, keywords);
|
|
987
1800
|
if (fileIndex.length === 0) {
|
|
988
|
-
|
|
1801
|
+
removeTaskLock(lockFile);
|
|
1802
|
+
await client.askQuestion(card.id, provider, `I could not find any source files to work on in \`${projectPath}\`. Is this the right folder for the "${card.board_name || 'this'}" board? If not, connect the correct project folder in the board's AI Agents settings and reply here.`);
|
|
989
1803
|
return false;
|
|
990
1804
|
}
|
|
991
1805
|
// 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
|
|
1806
|
+
const projectContext = readProjectInstructionsContext(projectPath, profile);
|
|
992
1807
|
const selectionRes = await client.autonomousStep({
|
|
993
1808
|
card_id: card.id,
|
|
994
1809
|
action: 'select_files',
|
|
995
1810
|
file_index: fileIndex,
|
|
1811
|
+
project_context: projectContext,
|
|
996
1812
|
});
|
|
997
|
-
|
|
1813
|
+
// The model may only choose from the files it was offered: a path it made up (../../.ssh/…, .env) is dropped
|
|
1814
|
+
const selectedFiles = (selectionRes.target_files || selectionRes.selected_files || [])
|
|
1815
|
+
.filter((f) => typeof f === 'string' && fileIndex.includes(f))
|
|
1816
|
+
.slice(0, 6);
|
|
998
1817
|
if (selectedFiles.length === 0) {
|
|
999
1818
|
selectedFiles.push(...fileIndex.slice(0, 4));
|
|
1000
1819
|
}
|
|
@@ -1017,12 +1836,17 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1017
1836
|
const full = path.join(projectPath, rel);
|
|
1018
1837
|
if (fs.existsSync(full)) {
|
|
1019
1838
|
try {
|
|
1020
|
-
|
|
1839
|
+
const raw = fs.readFileSync(full);
|
|
1840
|
+
// A file that is not valid UTF-8 would be silently corrupted by a text round-trip: leave it alone
|
|
1841
|
+
if (isUtf8(raw))
|
|
1842
|
+
filesPayload[rel] = raw.toString('utf8');
|
|
1021
1843
|
}
|
|
1022
1844
|
catch { }
|
|
1023
1845
|
}
|
|
1024
1846
|
}
|
|
1025
1847
|
if (Object.keys(filesPayload).length === 0) {
|
|
1848
|
+
removeTaskLock(lockFile);
|
|
1849
|
+
await client.askQuestion(card.id, provider, 'I could not read the files I selected for this task. Could you point me at the page, screen or file this should change?');
|
|
1026
1850
|
return false;
|
|
1027
1851
|
}
|
|
1028
1852
|
await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
|
|
@@ -1033,117 +1857,165 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1033
1857
|
card_id: card.id,
|
|
1034
1858
|
action: 'generate_edits',
|
|
1035
1859
|
files: filesPayload,
|
|
1860
|
+
project_context: projectContext,
|
|
1036
1861
|
});
|
|
1037
|
-
|
|
1862
|
+
// Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
|
|
1863
|
+
if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
|
|
1864
|
+
removeTaskLock(lockFile);
|
|
1865
|
+
appendIdeActivityLog(projectPath, {
|
|
1866
|
+
cardId: card.id,
|
|
1867
|
+
cardTitle: card.title,
|
|
1868
|
+
agentName: workerLabel,
|
|
1869
|
+
model: card.agent_model || 'auto',
|
|
1870
|
+
status: 'waiting_for_input',
|
|
1871
|
+
summary: `Needs clarification: ${editsRes.clarification_question}`,
|
|
1872
|
+
files: Object.keys(filesPayload),
|
|
1873
|
+
});
|
|
1874
|
+
await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
|
|
1875
|
+
return false;
|
|
1876
|
+
}
|
|
1877
|
+
// Only the files that were actually sent to the model may be edited (never .git/, never a path it invented)
|
|
1878
|
+
const editableFiles = new Set(Object.keys(filesPayload));
|
|
1879
|
+
const projectRoot = fs.realpathSync(projectPath);
|
|
1880
|
+
/**
|
|
1881
|
+
* Apply one set of edits. Returns the files changed and a reason for every edit that could not be applied.
|
|
1882
|
+
* A set of edits is one coordinated change: the caller ships it only if ALL of it applied.
|
|
1883
|
+
*/
|
|
1884
|
+
const applyEditsPass = async (editsList) => {
|
|
1038
1885
|
const modFiles = [];
|
|
1886
|
+
const failed = [];
|
|
1039
1887
|
for (const edit of editsList) {
|
|
1040
|
-
const relPath = edit.file || edit.path;
|
|
1041
|
-
if (!relPath || !edit.search || typeof edit.replace !== 'string')
|
|
1888
|
+
const relPath = (edit.file || edit.path || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
1889
|
+
if (!relPath || !edit.search || typeof edit.replace !== 'string') {
|
|
1890
|
+
failed.push(`${relPath || '(no file)'}: the edit was incomplete`);
|
|
1042
1891
|
continue;
|
|
1043
|
-
|
|
1044
|
-
if (!
|
|
1892
|
+
}
|
|
1893
|
+
if (!editableFiles.has(relPath)) {
|
|
1894
|
+
failed.push(`${relPath}: not one of the files provided`);
|
|
1045
1895
|
continue;
|
|
1046
|
-
|
|
1896
|
+
}
|
|
1897
|
+
const fullPath = path.resolve(projectPath, relPath);
|
|
1898
|
+
let realPath = '';
|
|
1899
|
+
try {
|
|
1900
|
+
realPath = fs.realpathSync(fullPath);
|
|
1901
|
+
}
|
|
1902
|
+
catch { }
|
|
1903
|
+
if (!realPath || !(realPath === projectRoot || realPath.startsWith(projectRoot + path.sep))) {
|
|
1904
|
+
failed.push(`${relPath}: outside the project`);
|
|
1047
1905
|
continue;
|
|
1906
|
+
}
|
|
1048
1907
|
const currentContent = fs.readFileSync(fullPath, 'utf8');
|
|
1049
|
-
if (!
|
|
1908
|
+
if (!currentContent.includes(edit.search)) {
|
|
1909
|
+
failed.push(`${relPath}: the \`search\` text was not found verbatim in the file`);
|
|
1910
|
+
continue;
|
|
1911
|
+
}
|
|
1912
|
+
const updatedContent = currentContent.replace(edit.search, () => edit.replace);
|
|
1913
|
+
if (updatedContent === currentContent)
|
|
1914
|
+
continue; // nothing to do is not a failure
|
|
1915
|
+
const countChar = (s, ch) => s.split(ch).length - 1;
|
|
1916
|
+
const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
|
|
1917
|
+
const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
|
|
1918
|
+
const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
|
|
1919
|
+
const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
|
|
1920
|
+
if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
|
|
1921
|
+
failed.push(`${relPath}: the replacement would leave braces or parentheses unbalanced`);
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
if (!originalContents.has(fullPath))
|
|
1050
1925
|
originalContents.set(fullPath, currentContent);
|
|
1926
|
+
fs.writeFileSync(fullPath, updatedContent, 'utf8');
|
|
1927
|
+
journalEdits(card.id, projectPath, originalContents);
|
|
1928
|
+
// Per-file syntax checks run without blocking the event loop (the worker heartbeat must keep ticking)
|
|
1929
|
+
if (relPath.endsWith('.dart')) {
|
|
1930
|
+
const lint = await exec('dart', ['analyze', fullPath], { cwd: projectPath, timeoutMs: 40000 });
|
|
1931
|
+
if (!lint.ok && /\berror\s+[•-]/i.test(lint.stdout + lint.stderr)) {
|
|
1932
|
+
fs.writeFileSync(fullPath, currentContent, 'utf8');
|
|
1933
|
+
failed.push(`${relPath}: dart analyze reported an error after the edit: ${lint.tail.slice(0, 300)}`);
|
|
1934
|
+
continue;
|
|
1935
|
+
}
|
|
1051
1936
|
}
|
|
1052
|
-
if (
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
|
|
1060
|
-
if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
fs.writeFileSync(fullPath, updatedContent, 'utf8');
|
|
1064
|
-
if (relPath.endsWith('.dart')) {
|
|
1065
|
-
try {
|
|
1066
|
-
execSync(`dart analyze "${fullPath}"`, {
|
|
1067
|
-
cwd: projectPath,
|
|
1068
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1069
|
-
timeout: 25000,
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
catch (analyzeErr) {
|
|
1073
|
-
const out = String(analyzeErr?.stdout || '') + String(analyzeErr?.stderr || '');
|
|
1074
|
-
if (/\berror\s+[•-]/i.test(out)) {
|
|
1075
|
-
fs.writeFileSync(fullPath, currentContent, 'utf8');
|
|
1076
|
-
continue;
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
else if (relPath.endsWith('.php')) {
|
|
1081
|
-
try {
|
|
1082
|
-
execSync(`php -l "${fullPath}"`, {
|
|
1083
|
-
cwd: projectPath,
|
|
1084
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1085
|
-
timeout: 10000,
|
|
1086
|
-
});
|
|
1087
|
-
}
|
|
1088
|
-
catch {
|
|
1089
|
-
fs.writeFileSync(fullPath, currentContent, 'utf8');
|
|
1090
|
-
continue;
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
if (!modFiles.includes(relPath)) {
|
|
1094
|
-
modFiles.push(relPath);
|
|
1095
|
-
}
|
|
1937
|
+
else if (relPath.endsWith('.php')) {
|
|
1938
|
+
const lint = await exec('php', ['-l', fullPath], { cwd: projectPath, timeoutMs: 15000 });
|
|
1939
|
+
// exit code null = `php` is not installed here: that must not discard valid work
|
|
1940
|
+
if (!lint.ok && lint.code !== null && !lint.timedOut) {
|
|
1941
|
+
fs.writeFileSync(fullPath, currentContent, 'utf8');
|
|
1942
|
+
failed.push(`${relPath}: PHP syntax error after the edit: ${lint.tail.slice(0, 300)}`);
|
|
1943
|
+
continue;
|
|
1096
1944
|
}
|
|
1097
1945
|
}
|
|
1946
|
+
if (!modFiles.includes(relPath))
|
|
1947
|
+
modFiles.push(relPath);
|
|
1098
1948
|
}
|
|
1099
|
-
return modFiles;
|
|
1949
|
+
return { modFiles, failed };
|
|
1100
1950
|
};
|
|
1101
|
-
const
|
|
1102
|
-
|
|
1103
|
-
// Pre-submit frontend build verification gate for root AND monorepo subdirectories (frontend/, web/, client/)
|
|
1104
|
-
const verifyFrontendBuilds = () => {
|
|
1105
|
-
const hasFrontendEdits = modifiedFiles.some((f) => /\.(tsx?|jsx?|vue|css)$/i.test(f));
|
|
1106
|
-
if (!hasFrontendEdits)
|
|
1107
|
-
return { ok: true };
|
|
1108
|
-
const candidateDirs = [
|
|
1109
|
-
projectPath,
|
|
1110
|
-
path.join(projectPath, 'frontend'),
|
|
1111
|
-
path.join(projectPath, 'web'),
|
|
1112
|
-
path.join(projectPath, 'client'),
|
|
1113
|
-
];
|
|
1114
|
-
for (const dir of candidateDirs) {
|
|
1115
|
-
const pkgJsonPath = path.join(dir, 'package.json');
|
|
1116
|
-
if (!fs.existsSync(pkgJsonPath) || !fs.existsSync(path.join(dir, 'node_modules')))
|
|
1117
|
-
continue;
|
|
1951
|
+
const revertAll = () => {
|
|
1952
|
+
for (const [fPath, orig] of originalContents.entries()) {
|
|
1118
1953
|
try {
|
|
1119
|
-
|
|
1120
|
-
if (pkg?.scripts?.build) {
|
|
1121
|
-
const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))
|
|
1122
|
-
? 'pnpm run build'
|
|
1123
|
-
: 'npm run build';
|
|
1124
|
-
execSync(buildCmd, {
|
|
1125
|
-
cwd: dir,
|
|
1126
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1127
|
-
timeout: 60000,
|
|
1128
|
-
});
|
|
1129
|
-
}
|
|
1954
|
+
fs.writeFileSync(fPath, orig, 'utf8');
|
|
1130
1955
|
}
|
|
1131
|
-
catch
|
|
1132
|
-
|
|
1133
|
-
|
|
1956
|
+
catch { }
|
|
1957
|
+
}
|
|
1958
|
+
originalContents.clear();
|
|
1959
|
+
clearEditJournal(card.id);
|
|
1960
|
+
};
|
|
1961
|
+
// Never build on top of the developer's own uncommitted work in the same file: a whole-file commit would
|
|
1962
|
+
// ship their half-finished changes under this card's name.
|
|
1963
|
+
const targetFiles = Array.from(new Set((editsRes.edits || []).map((e) => (e.file || e.path || '').replace(/\\/g, '/').replace(/^\.\//, '')).filter((f) => editableFiles.has(f))));
|
|
1964
|
+
if (targetFiles.length > 0) {
|
|
1965
|
+
const status = await exec('git', ['--literal-pathspecs', 'status', '--porcelain', '--', ...targetFiles], { cwd: projectPath, timeoutMs: 20000 });
|
|
1966
|
+
const dirty = status.ok
|
|
1967
|
+
? status.stdout
|
|
1968
|
+
.split('\n')
|
|
1969
|
+
.filter((l) => l.trim() && !l.startsWith('??'))
|
|
1970
|
+
.map((l) => l.slice(3).trim())
|
|
1971
|
+
.filter((f) => !carryOverFiles.includes(f))
|
|
1972
|
+
: [];
|
|
1973
|
+
if (dirty.length > 0) {
|
|
1974
|
+
removeTaskLock(lockFile);
|
|
1975
|
+
await client.askQuestion(card.id, provider, `I need to change ${dirty.map((f) => `\`${f}\``).join(', ')}, but ${dirty.length === 1 ? 'that file has' : 'those files have'} uncommitted changes in \`${projectPath}\` that are not mine. Committing my edit would also commit that unfinished work, so I stopped.\n\nCommit or stash those changes, then reply here and I will continue.`);
|
|
1976
|
+
return false;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
let pass = await applyEditsPass(editsRes.edits || []);
|
|
1980
|
+
if (pass.failed.length > 0) {
|
|
1981
|
+
// Half of a coordinated change is worse than none: undo it and give the model one chance to correct itself
|
|
1982
|
+
revertAll();
|
|
1983
|
+
editsRes = await client.autonomousStep({
|
|
1984
|
+
card_id: card.id,
|
|
1985
|
+
action: 'generate_edits',
|
|
1986
|
+
files: {
|
|
1987
|
+
...filesPayload,
|
|
1988
|
+
'__EDITS_THAT_DID_NOT_APPLY__.txt': `Your previous edits were NOT applied because some of them could not be applied, and a partial change must never ship:\n${pass.failed.join('\n')}\nReturn the complete set of edits again. Every \`search\` must be copied verbatim from the current file contents, and only files listed in \`files\` may be edited.`,
|
|
1989
|
+
},
|
|
1990
|
+
project_context: projectContext,
|
|
1991
|
+
});
|
|
1992
|
+
pass = await applyEditsPass(editsRes.edits || []);
|
|
1993
|
+
if (pass.failed.length > 0) {
|
|
1994
|
+
revertAll();
|
|
1995
|
+
pass = { modFiles: [], failed: pass.failed };
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
const modifiedFiles = pass.modFiles;
|
|
1999
|
+
const applyFailures = pass.failed;
|
|
2000
|
+
// Pre-submit build gate: build the package that owns each changed front-end file (works for any monorepo layout)
|
|
2001
|
+
const verifyFrontendBuilds = async () => {
|
|
2002
|
+
for (const dir of frontendBuildDirs(projectPath, modifiedFiles)) {
|
|
2003
|
+
const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm';
|
|
2004
|
+
const res = await exec(buildCmd, ['run', 'build'], { cwd: dir, timeoutMs: 6 * 60 * 1000 });
|
|
2005
|
+
if (res.timedOut) {
|
|
2006
|
+
// A slow build is not a compile error: do not throw the work away over it
|
|
2007
|
+
return { ok: true, note: `The build in \`${path.relative(projectPath, dir) || '.'}\` did not finish within 6 minutes, so it was not verified locally.` };
|
|
2008
|
+
}
|
|
2009
|
+
if (!res.ok) {
|
|
2010
|
+
return { ok: false, errorOutput: `${res.stdout}\n${res.stderr}`.trim().slice(-1500) };
|
|
1134
2011
|
}
|
|
1135
2012
|
}
|
|
1136
2013
|
return { ok: true };
|
|
1137
2014
|
};
|
|
1138
|
-
let buildCheck = verifyFrontendBuilds();
|
|
2015
|
+
let buildCheck = await verifyFrontendBuilds();
|
|
1139
2016
|
if (!buildCheck.ok) {
|
|
1140
2017
|
// Revert first pass, attach compiler error context, and do 1 self-correction pass
|
|
1141
|
-
|
|
1142
|
-
try {
|
|
1143
|
-
fs.writeFileSync(fPath, orig, 'utf8');
|
|
1144
|
-
}
|
|
1145
|
-
catch { }
|
|
1146
|
-
}
|
|
2018
|
+
revertAll();
|
|
1147
2019
|
modifiedFiles.length = 0;
|
|
1148
2020
|
const retryFiles = {
|
|
1149
2021
|
...filesPayload,
|
|
@@ -1153,228 +2025,245 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1153
2025
|
card_id: card.id,
|
|
1154
2026
|
action: 'generate_edits',
|
|
1155
2027
|
files: retryFiles,
|
|
2028
|
+
project_context: projectContext,
|
|
1156
2029
|
});
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
2030
|
+
const retryPass = await applyEditsPass(editsRes.edits || []);
|
|
2031
|
+
if (retryPass.failed.length > 0) {
|
|
2032
|
+
revertAll();
|
|
2033
|
+
applyFailures.push(...retryPass.failed);
|
|
2034
|
+
}
|
|
2035
|
+
else {
|
|
2036
|
+
modifiedFiles.push(...retryPass.modFiles);
|
|
2037
|
+
buildCheck = await verifyFrontendBuilds();
|
|
2038
|
+
if (!buildCheck.ok) {
|
|
2039
|
+
revertAll();
|
|
2040
|
+
modifiedFiles.length = 0;
|
|
1166
2041
|
}
|
|
1167
|
-
modifiedFiles.length = 0;
|
|
1168
2042
|
}
|
|
1169
2043
|
}
|
|
1170
|
-
if (modifiedFiles.length
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
2044
|
+
if (modifiedFiles.length === 0) {
|
|
2045
|
+
removeTaskLock(lockFile);
|
|
2046
|
+
revertAll();
|
|
2047
|
+
const buildNote = buildCheck && !buildCheck.ok && buildCheck.errorOutput
|
|
2048
|
+
? `\n\nThe attempted change did not compile, so it was reverted. Last build error:\n${buildCheck.errorOutput.split('\n').filter((l) => /error/i.test(l)).slice(0, 4).join('\n').slice(0, 600)}`
|
|
2049
|
+
: applyFailures.length > 0
|
|
2050
|
+
? `\n\nThe change I generated could not be applied cleanly, so nothing was modified:\n${applyFailures.slice(0, 4).map((f) => `- ${f}`).join('\n').slice(0, 700)}`
|
|
2051
|
+
: '';
|
|
2052
|
+
await client.askQuestion(card.id, provider, `I inspected ${Object.keys(filesPayload)
|
|
2053
|
+
.map((f) => `\`${f}\``)
|
|
2054
|
+
.join(', ')} but could not make a verified change automatically.${buildNote}\n\nCould you describe exactly where in the app this should appear (page or screen name) and what you expect to see?`);
|
|
2055
|
+
return false;
|
|
2056
|
+
}
|
|
2057
|
+
const commitFiles = Array.from(new Set([...carryOverFiles, ...modifiedFiles]));
|
|
2058
|
+
let rawDiff = '';
|
|
2059
|
+
try {
|
|
2060
|
+
rawDiff = (await exec('git', ['--literal-pathspecs', 'diff', '--', ...modifiedFiles], { cwd: projectPath, timeoutMs: 20000 })).stdout.trim();
|
|
2061
|
+
}
|
|
2062
|
+
catch { }
|
|
2063
|
+
const baseSummary = editsRes.summary ||
|
|
2064
|
+
`Updated ${modifiedFiles.join(', ')} to complete "${card.title}".`;
|
|
2065
|
+
const plainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() + (buildCheck.note ? `\n\n${buildCheck.note}` : '');
|
|
2066
|
+
const verificationSteps = Array.isArray(editsRes.verification_steps)
|
|
2067
|
+
? editsRes.verification_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')
|
|
2068
|
+
: (typeof editsRes.verification_steps === 'string' && editsRes.verification_steps.trim() !== ''
|
|
2069
|
+
? editsRes.verification_steps
|
|
2070
|
+
: `1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`);
|
|
2071
|
+
const cardState = (stage, extra = {}) => ({
|
|
2072
|
+
stage,
|
|
2073
|
+
files: commitFiles,
|
|
2074
|
+
summary: plainSummary,
|
|
2075
|
+
verification_steps: verificationSteps,
|
|
2076
|
+
diff: rawDiff.slice(0, 20000),
|
|
2077
|
+
commit: null,
|
|
2078
|
+
at: new Date().toISOString(),
|
|
2079
|
+
attempts: 1,
|
|
2080
|
+
...extra,
|
|
2081
|
+
});
|
|
2082
|
+
// Gate C: commit ONLY the files changed for this card, run the project's real deployment route, and VERIFY it.
|
|
2083
|
+
// With deploy_stage 'done' or 'manual' the change waits (uncommitted) for approval and deploys then.
|
|
2084
|
+
const deployStage = (card.deploy_stage || 'review').toLowerCase();
|
|
2085
|
+
let deployedNote = '';
|
|
2086
|
+
// From here on the edits are owned by the card's saved state (deploy / approval), not by this run's revert safety net
|
|
2087
|
+
editsHandedOff = true;
|
|
2088
|
+
clearEditJournal(card.id);
|
|
2089
|
+
if (deployStage === 'review') {
|
|
2090
|
+
// Persist first: if this process dies mid-deploy, the re-queued card resumes with the deployment only
|
|
2091
|
+
setCardState(projectPath, card.id, cardState('deploy_failed', { last_error: 'The deployment was interrupted before it finished.' }));
|
|
2092
|
+
await client.addProgress(card.id, provider, `Change is ready. Deploying via **${methodLabel(profile.method)}** — ${profile.evidence[0] || 'best match found in the project'}.`);
|
|
2093
|
+
const deployInfo = await runDeployment({
|
|
2094
|
+
projectPath,
|
|
2095
|
+
title: card.title || 'update',
|
|
1199
2096
|
cardId: card.id,
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
model: card.agent_model || 'auto',
|
|
1203
|
-
status: 'review',
|
|
1204
|
-
summary: cleanPlainSummary,
|
|
1205
|
-
files: modifiedFiles,
|
|
1206
|
-
diff: rawDiff,
|
|
2097
|
+
files: commitFiles,
|
|
2098
|
+
bound: boundProfileOf(card),
|
|
1207
2099
|
});
|
|
1208
|
-
await client
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
2100
|
+
await reportOutcome(client, card, projectPath, deployInfo);
|
|
2101
|
+
if (!deployInfo.ok) {
|
|
2102
|
+
// Never claim success: keep the card with the human, and remember the change so a reply retries ONLY the deployment
|
|
2103
|
+
setCardState(projectPath, card.id, cardState('deploy_failed', {
|
|
2104
|
+
commit: deployInfo.commitHash || null,
|
|
2105
|
+
last_error: deployInfo.errorDetail,
|
|
2106
|
+
pending_confirmation: deployInfo.errorKind === 'needs_confirmation' ? { method: deployInfo.method, command: deployInfo.command } : null,
|
|
2107
|
+
}));
|
|
2108
|
+
removeTaskLock(lockFile);
|
|
2109
|
+
appendIdeActivityLog(projectPath, {
|
|
2110
|
+
cardId: card.id,
|
|
2111
|
+
cardTitle: card.title,
|
|
2112
|
+
agentName: workerLabel,
|
|
2113
|
+
model: card.agent_model || 'auto',
|
|
2114
|
+
status: 'deploy_failed',
|
|
2115
|
+
summary: `Code change was made but deployment could not be verified: ${deployInfo.errorDetail.slice(0, 700)}`,
|
|
2116
|
+
files: modifiedFiles,
|
|
2117
|
+
diff: rawDiff,
|
|
2118
|
+
});
|
|
2119
|
+
const changed = modifiedFiles.map((f) => `\`${f}\``).join(', ');
|
|
2120
|
+
await client.askQuestion(card.id, provider, deployInfo.errorKind === 'needs_confirmation'
|
|
2121
|
+
? `I made the code change (${changed}). One question before I deploy this project for the first time:\n\n${describeFailureForHuman(deployInfo)}`
|
|
2122
|
+
: `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`);
|
|
2123
|
+
return false;
|
|
1220
2124
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
2125
|
+
if (deployInfo.pending && deployInfo.pendingVerification) {
|
|
2126
|
+
addPendingVerification(projectPath, { ...deployInfo.pendingVerification, provider });
|
|
2127
|
+
}
|
|
2128
|
+
setCardState(projectPath, card.id, null);
|
|
2129
|
+
deployedNote = deployInfo.deployedNote;
|
|
1224
2130
|
}
|
|
1225
2131
|
else {
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
await client.askQuestion(card.id, provider, `Inspected ${Object.keys(filesPayload)
|
|
1232
|
-
.map((f) => `\`${f}\``)
|
|
1233
|
-
.join(', ')}, but needed more specific details to apply a verified code change automatically. Reply in the comments with additional details (or open your IDE agent) to continue!`);
|
|
1234
|
-
return false;
|
|
2132
|
+
setCardState(projectPath, card.id, cardState('awaiting_approval'));
|
|
2133
|
+
deployedNote =
|
|
2134
|
+
deployStage === 'manual'
|
|
2135
|
+
? '📦 **Changes saved locally** (not committed). This project is set to manual deployment.'
|
|
2136
|
+
: `📦 **Changes saved locally** (not committed yet). They will be committed and deployed via ${methodLabel(profile.method)} when you approve this card.`;
|
|
1235
2137
|
}
|
|
2138
|
+
const cleanPlainSummary = `${plainSummary}\n\n${deployedNote}`.trim();
|
|
2139
|
+
const logLines = [
|
|
2140
|
+
`Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
|
|
2141
|
+
`Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
|
|
2142
|
+
`Selected files: ${Object.keys(filesPayload).join(', ')}`,
|
|
2143
|
+
`Modified & verified files: ${modifiedFiles.join(', ')}`,
|
|
2144
|
+
`Deployment route: ${profile.method} (${profile.confidence} confidence, ${profile.source})`,
|
|
2145
|
+
`Deployment: ${deployedNote}`,
|
|
2146
|
+
`Summary: ${cleanPlainSummary}`,
|
|
2147
|
+
];
|
|
2148
|
+
appendIdeActivityLog(projectPath, {
|
|
2149
|
+
cardId: card.id,
|
|
2150
|
+
cardTitle: card.title,
|
|
2151
|
+
agentName: workerLabel,
|
|
2152
|
+
model: card.agent_model || 'auto',
|
|
2153
|
+
status: 'review',
|
|
2154
|
+
summary: cleanPlainSummary,
|
|
2155
|
+
files: modifiedFiles,
|
|
2156
|
+
diff: rawDiff,
|
|
2157
|
+
});
|
|
2158
|
+
await client.submitReview(card.id, provider, cleanPlainSummary, verificationSteps, undefined, undefined, {
|
|
2159
|
+
diffText: rawDiff,
|
|
2160
|
+
logText: logLines.join('\n'),
|
|
2161
|
+
filesModified: modifiedFiles.join(', '),
|
|
2162
|
+
});
|
|
2163
|
+
writeTaskLock(lockFile, card, 'completed');
|
|
2164
|
+
sendDesktopNotification(`Agile Monster -> Ready for Review`, `Completed "${card.title}" (${modifiedFiles.length} file(s) updated) and moved to Testing / Review!`);
|
|
2165
|
+
log.info('card.review', { card: card.id, files: modifiedFiles.length, deploy: profile.method });
|
|
2166
|
+
return true;
|
|
1236
2167
|
}
|
|
1237
2168
|
catch (err) {
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
2169
|
+
removeTaskLock(lockFile);
|
|
2170
|
+
// An interrupted run must not leave half-applied, uncommitted edits in the developer's working tree
|
|
2171
|
+
const rolledBack = !editsHandedOff && originalContents.size > 0;
|
|
2172
|
+
if (rolledBack) {
|
|
2173
|
+
for (const [fPath, orig] of originalContents.entries()) {
|
|
2174
|
+
try {
|
|
2175
|
+
fs.writeFileSync(fPath, orig, 'utf8');
|
|
2176
|
+
}
|
|
2177
|
+
catch { }
|
|
2178
|
+
}
|
|
1241
2179
|
}
|
|
1242
|
-
|
|
2180
|
+
if (!editsHandedOff)
|
|
2181
|
+
clearEditJournal(card.id);
|
|
2182
|
+
log.error('card.failed', { card: card.id, project: projectPath, error: errorMessage(err), reverted: rolledBack });
|
|
1243
2183
|
try {
|
|
1244
|
-
await client.addProgress(card.id, provider, `Autonomous runner note: ${err?.message || String(err)}`);
|
|
2184
|
+
await client.addProgress(card.id, provider, `Autonomous runner note: ${err?.message || String(err)}${rolledBack ? ' (partial edits were rolled back)' : ''}`);
|
|
1245
2185
|
}
|
|
1246
2186
|
catch { }
|
|
1247
2187
|
return false;
|
|
1248
2188
|
}
|
|
1249
2189
|
finally {
|
|
1250
2190
|
activeAutonomousCards.delete(card.id);
|
|
2191
|
+
activeWork--;
|
|
1251
2192
|
}
|
|
1252
2193
|
}
|
|
1253
2194
|
/**
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
2195
|
+
* Finish a card the human approved into Done (or flagged as `deploying`).
|
|
2196
|
+
* Idempotent: keyed on card ID only (never on `updated_at`, which changes whenever we post a comment).
|
|
2197
|
+
*
|
|
2198
|
+
* Approving a card that was already deployed and verified at review time only CONFIRMS it — it no longer
|
|
2199
|
+
* triggers a second deployment (or an empty "redeploy" commit) for every approval.
|
|
1258
2200
|
*/
|
|
1259
|
-
function runProjectDeploymentPipeline(projectPath, rawTitle, shortId, forceEmptyCommit = false) {
|
|
1260
|
-
const cleanTitle = (rawTitle || 'update').replace(/["`\r\n]+/g, ' ').trim();
|
|
1261
|
-
let branch = 'main';
|
|
1262
|
-
let commitHash = '';
|
|
1263
|
-
const actions = [];
|
|
1264
|
-
try {
|
|
1265
|
-
branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
1266
|
-
cwd: projectPath,
|
|
1267
|
-
encoding: 'utf8',
|
|
1268
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1269
|
-
}).trim() || 'main';
|
|
1270
|
-
const statusOut = execSync('git status --porcelain -uno', {
|
|
1271
|
-
cwd: projectPath,
|
|
1272
|
-
encoding: 'utf8',
|
|
1273
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1274
|
-
}).trim();
|
|
1275
|
-
if (statusOut.length > 0) {
|
|
1276
|
-
execSync('git add -u', { cwd: projectPath, stdio: 'ignore' });
|
|
1277
|
-
execSync(`git commit -m "feat: ${cleanTitle} [AM-${shortId}]"`, {
|
|
1278
|
-
cwd: projectPath,
|
|
1279
|
-
stdio: 'ignore',
|
|
1280
|
-
});
|
|
1281
|
-
execSync(`git push origin ${branch}`, {
|
|
1282
|
-
cwd: projectPath,
|
|
1283
|
-
stdio: 'ignore',
|
|
1284
|
-
timeout: 45000,
|
|
1285
|
-
});
|
|
1286
|
-
commitHash = execSync('git rev-parse --short HEAD', {
|
|
1287
|
-
cwd: projectPath,
|
|
1288
|
-
encoding: 'utf8',
|
|
1289
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1290
|
-
}).trim();
|
|
1291
|
-
actions.push(`pushed \`${commitHash}\` to \`origin/${branch}\``);
|
|
1292
|
-
}
|
|
1293
|
-
else if (forceEmptyCommit) {
|
|
1294
|
-
execSync(`git commit --allow-empty -m "chore: trigger container redeploy [AM-${shortId}]"`, {
|
|
1295
|
-
cwd: projectPath,
|
|
1296
|
-
stdio: 'ignore',
|
|
1297
|
-
});
|
|
1298
|
-
execSync(`git push origin ${branch}`, {
|
|
1299
|
-
cwd: projectPath,
|
|
1300
|
-
stdio: 'ignore',
|
|
1301
|
-
timeout: 45000,
|
|
1302
|
-
});
|
|
1303
|
-
commitHash = execSync('git rev-parse --short HEAD', {
|
|
1304
|
-
cwd: projectPath,
|
|
1305
|
-
encoding: 'utf8',
|
|
1306
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1307
|
-
}).trim();
|
|
1308
|
-
actions.push(`triggered redeploy commit \`${commitHash}\` on \`origin/${branch}\``);
|
|
1309
|
-
}
|
|
1310
|
-
else {
|
|
1311
|
-
commitHash = execSync('git rev-parse --short HEAD', {
|
|
1312
|
-
cwd: projectPath,
|
|
1313
|
-
encoding: 'utf8',
|
|
1314
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1315
|
-
}).trim();
|
|
1316
|
-
}
|
|
1317
|
-
// Check if the project has a dedicated deploy script (e.g. deploy/deploy.sh in Meerlink)
|
|
1318
|
-
const deployScript = path.join(projectPath, 'deploy', 'deploy.sh');
|
|
1319
|
-
if (fs.existsSync(deployScript)) {
|
|
1320
|
-
const scriptContent = fs.readFileSync(deployScript, 'utf8');
|
|
1321
|
-
const flag = scriptContent.includes('--skip-preflight') ? ' --skip-preflight' : '';
|
|
1322
|
-
execSync(`bash "${deployScript}"${flag}`, {
|
|
1323
|
-
cwd: projectPath,
|
|
1324
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1325
|
-
timeout: 180000,
|
|
1326
|
-
});
|
|
1327
|
-
actions.push('deployed live via `deploy/deploy.sh`');
|
|
1328
|
-
}
|
|
1329
|
-
else if (fs.existsSync(path.join(projectPath, '.github', 'workflows'))) {
|
|
1330
|
-
actions.push('triggered GitHub Actions CI/CD deployment');
|
|
1331
|
-
}
|
|
1332
|
-
const deployedNote = actions.length > 0
|
|
1333
|
-
? `🚀 **Deployed**: Automatically ${actions.join(' and ')}.`
|
|
1334
|
-
: `🚀 **Deployed**: Verified commit \`${commitHash}\` is live on \`origin/${branch}\`.`;
|
|
1335
|
-
return { ok: true, commitHash, branch, deployedNote };
|
|
1336
|
-
}
|
|
1337
|
-
catch (err) {
|
|
1338
|
-
return {
|
|
1339
|
-
ok: false,
|
|
1340
|
-
commitHash,
|
|
1341
|
-
branch,
|
|
1342
|
-
deployedNote: '',
|
|
1343
|
-
};
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
2201
|
export async function deployApprovedCardIfNeeded(client, card, projectPath) {
|
|
1347
2202
|
if (!projectPath || !fs.existsSync(projectPath))
|
|
1348
2203
|
return false;
|
|
1349
2204
|
const shortId = card.id.slice(0, 8);
|
|
1350
2205
|
const deployLock = path.join(getAgileMonsterLocksDir(), `deployed-${shortId}.json`);
|
|
1351
|
-
|
|
2206
|
+
let existing = null;
|
|
1352
2207
|
if (fs.existsSync(deployLock)) {
|
|
1353
2208
|
try {
|
|
1354
|
-
|
|
1355
|
-
if (!cardUpdatedAt || existing?.card_updated_at === cardUpdatedAt) {
|
|
1356
|
-
return false;
|
|
1357
|
-
}
|
|
2209
|
+
existing = JSON.parse(fs.readFileSync(deployLock, 'utf8'));
|
|
1358
2210
|
}
|
|
1359
2211
|
catch {
|
|
2212
|
+
existing = { status: 'deployed' }; // unreadable lock: fail safe (never loop)
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
const awaitingDeploy = card.agent_status === 'deploying';
|
|
2216
|
+
const priorAttempts = Number(existing?.attempts || 0);
|
|
2217
|
+
if (existing) {
|
|
2218
|
+
// Already handled and nobody is waiting on a deployment
|
|
2219
|
+
if (!awaitingDeploy)
|
|
1360
2220
|
return false;
|
|
2221
|
+
const age = Date.now() - (Date.parse(existing.started_at || '') || 0);
|
|
2222
|
+
if (existing.status === 'waiting_ci') {
|
|
2223
|
+
// re-check the pipeline on this pulse
|
|
2224
|
+
}
|
|
2225
|
+
else if (existing.status === 'running') {
|
|
2226
|
+
if (age < 30 * 60 * 1000)
|
|
2227
|
+
return false; // another run is (or was very recently) on it
|
|
2228
|
+
}
|
|
2229
|
+
else if (existing.status === 'failed') {
|
|
2230
|
+
// The card is STILL waiting. Either the human approved again (card changed -> retry now), or we never
|
|
2231
|
+
// managed to tell them (the API call failed): retry with a growing pause instead of going silent forever.
|
|
2232
|
+
if (existing.redeploy_of === card.updated_at) {
|
|
2233
|
+
if (priorAttempts >= 6)
|
|
2234
|
+
return false;
|
|
2235
|
+
if (age < Math.min(60, 2 ** priorAttempts) * 60 * 1000)
|
|
2236
|
+
return false;
|
|
2237
|
+
}
|
|
1361
2238
|
}
|
|
2239
|
+
else if (age < 60 * 1000) {
|
|
2240
|
+
return false; // just finished; give the board a moment to reflect it
|
|
2241
|
+
}
|
|
2242
|
+
// status 'deployed' while the card still says "deploying": completing it failed last time, or the human
|
|
2243
|
+
// asked again. Falling through confirms it (no second deployment when it already shipped).
|
|
1362
2244
|
}
|
|
2245
|
+
const deployStage = (card.deploy_stage || 'review').toLowerCase();
|
|
1363
2246
|
const provider = card.agent_provider || 'cursor';
|
|
1364
|
-
|
|
1365
|
-
const isContainerRestartCard =
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2247
|
+
// Only a card that literally asks for a redeploy gets one; ordinary cards never trigger a second deployment
|
|
2248
|
+
const isContainerRestartCard = isRedeployOnlyCard(card);
|
|
2249
|
+
const writeLock = (status, extra = {}) => {
|
|
2250
|
+
try {
|
|
2251
|
+
fs.writeFileSync(deployLock, JSON.stringify({
|
|
2252
|
+
card_id: card.id,
|
|
2253
|
+
status,
|
|
2254
|
+
title: card.title,
|
|
2255
|
+
redeploy_of: awaitingDeploy ? card.updated_at : null,
|
|
2256
|
+
started_at: new Date().toISOString(),
|
|
2257
|
+
attempts: status === 'failed' ? priorAttempts + 1 : priorAttempts,
|
|
2258
|
+
...extra,
|
|
2259
|
+
}), 'utf8');
|
|
2260
|
+
}
|
|
2261
|
+
catch { }
|
|
2262
|
+
};
|
|
2263
|
+
const confirmDone = async (msg, commit) => {
|
|
2264
|
+
await withRetry('completeTask', () => client.completeTask(card.id, provider, msg));
|
|
2265
|
+
writeLock('deployed', { commit: commit || null, completed_at: new Date().toISOString() });
|
|
2266
|
+
setCardState(projectPath, card.id, null);
|
|
1378
2267
|
appendIdeActivityLog(projectPath, {
|
|
1379
2268
|
cardId: card.id,
|
|
1380
2269
|
cardTitle: card.title,
|
|
@@ -1383,26 +2272,234 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
|
|
|
1383
2272
|
status: 'done',
|
|
1384
2273
|
summary: msg,
|
|
1385
2274
|
});
|
|
1386
|
-
|
|
2275
|
+
};
|
|
2276
|
+
activeWork++;
|
|
2277
|
+
writeLock('running');
|
|
2278
|
+
try {
|
|
2279
|
+
if (deployStage === 'manual') {
|
|
2280
|
+
// A human ships this project: nothing is waiting on us any more
|
|
2281
|
+
setCardState(projectPath, card.id, null);
|
|
2282
|
+
writeLock('deployed', { note: 'manual deploy stage' });
|
|
2283
|
+
return false;
|
|
2284
|
+
}
|
|
2285
|
+
const memory = loadMemory(projectPath);
|
|
2286
|
+
const state = memory.cards[card.id] || null;
|
|
2287
|
+
const stillPending = memory.pending.find((p) => p.card_id === card.id) || null;
|
|
2288
|
+
const earlierSuccess = memory.deployed_cards[card.id] || null;
|
|
2289
|
+
// The deployment started at review time is still running in CI: wait for its verdict rather than deploying again
|
|
2290
|
+
if (stillPending && !isContainerRestartCard) {
|
|
2291
|
+
const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory });
|
|
2292
|
+
const check = await checkCiOnce(projectPath, stillPending.sha, stillPending.workflow_names, profile.workflows.some((w) => w.isDeploy && w.workflowRun));
|
|
2293
|
+
if (check.verdict === 'success') {
|
|
2294
|
+
removePendingVerification(projectPath, card.id);
|
|
2295
|
+
recordAttempt(projectPath, { card_id: card.id, method: 'github_actions', ok: true, commit: stillPending.sha.slice(0, 7) });
|
|
2296
|
+
if (awaitingDeploy) {
|
|
2297
|
+
await confirmDone(`🚀 **Deployed**: GitHub Actions deployment passed for \`${stillPending.sha.slice(0, 7)}\`. Confirmed and moved card to **Done**.`, stillPending.sha.slice(0, 7));
|
|
2298
|
+
return true;
|
|
2299
|
+
}
|
|
2300
|
+
writeLock('deployed', { note: 'verified after review' });
|
|
2301
|
+
return false;
|
|
2302
|
+
}
|
|
2303
|
+
if (check.verdict === 'failure') {
|
|
2304
|
+
removePendingVerification(projectPath, card.id);
|
|
2305
|
+
writeLock('failed', { error: check.reason });
|
|
2306
|
+
await withRetry('blockTask', () => client.blockTask(card.id, provider, `⚠️ The deployment for this card failed in GitHub Actions, so it was NOT marked as deployed.\n\nWhat failed: ${check.reason || 'the deployment workflow failed'} ${check.url || ''}${check.logTail ? `\nFailing step output: ${check.logTail}` : ''}\n\nFix the issue, then approve the card again to retry.`));
|
|
2307
|
+
return false;
|
|
2308
|
+
}
|
|
2309
|
+
writeLock('waiting_ci', { sha: stillPending.sha });
|
|
2310
|
+
return false;
|
|
2311
|
+
}
|
|
2312
|
+
if (!state) {
|
|
2313
|
+
if (earlierSuccess) {
|
|
2314
|
+
// This machine already shipped the card before it reached Testing — approval just confirms it.
|
|
2315
|
+
if (awaitingDeploy) {
|
|
2316
|
+
await confirmDone(earlierSuccess.verified
|
|
2317
|
+
? `🚀 **Deployed**: already live and verified at review time (commit \`${earlierSuccess.commit || 'n/a'}\`). Confirmed and moved card to **Done**.`
|
|
2318
|
+
: `📤 This change was already pushed at review time (commit \`${earlierSuccess.commit || 'n/a'}\`); its deployment could not be verified automatically. Moved card to **Done**.`, earlierSuccess.commit || undefined);
|
|
2319
|
+
return true;
|
|
2320
|
+
}
|
|
2321
|
+
writeLock('deployed', { note: 'deployed at review stage' });
|
|
2322
|
+
return false;
|
|
2323
|
+
}
|
|
2324
|
+
if (!awaitingDeploy) {
|
|
2325
|
+
// Already Done and nobody is waiting on a deployment: nothing to do.
|
|
2326
|
+
writeLock('deployed', { note: 'no deployment requested' });
|
|
2327
|
+
return false;
|
|
2328
|
+
}
|
|
2329
|
+
// Approved, but this machine has no record of deploying it (e.g. an IDE agent did the work):
|
|
2330
|
+
// fall through and ship what is committed. Nothing new is committed on the card's behalf.
|
|
2331
|
+
}
|
|
2332
|
+
const res = await runDeployment({
|
|
2333
|
+
projectPath,
|
|
2334
|
+
title: card.title || 'update',
|
|
2335
|
+
cardId: card.id,
|
|
2336
|
+
files: state?.files,
|
|
2337
|
+
redeploy: isContainerRestartCard,
|
|
2338
|
+
bound: boundProfileOf(card),
|
|
2339
|
+
});
|
|
2340
|
+
await reportOutcome(client, card, projectPath, res);
|
|
2341
|
+
if (!res.ok) {
|
|
2342
|
+
writeLock('failed', { error: res.errorDetail });
|
|
2343
|
+
// Keep what is needed so a reply ("retry" / "deploy: …") redoes only the deployment
|
|
2344
|
+
setCardState(projectPath, card.id, {
|
|
2345
|
+
stage: 'deploy_failed',
|
|
2346
|
+
files: state?.files || [],
|
|
2347
|
+
summary: state?.summary || `Deployment of "${card.title}".`,
|
|
2348
|
+
verification_steps: state?.verification_steps || `1. Check the live update for "${card.title}".`,
|
|
2349
|
+
diff: state?.diff || '',
|
|
2350
|
+
commit: res.commitHash || null,
|
|
2351
|
+
at: new Date().toISOString(),
|
|
2352
|
+
attempts: (state?.attempts || 0) + 1,
|
|
2353
|
+
last_error: res.errorDetail,
|
|
2354
|
+
pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
|
|
2355
|
+
});
|
|
2356
|
+
await withRetry('blockTask', () => client.blockTask(card.id, provider, res.errorKind === 'needs_confirmation'
|
|
2357
|
+
? `⚠️ This card is approved, but I need one answer before deploying this project for the first time.\n\n${describeFailureForHuman(res)}`
|
|
2358
|
+
: `⚠️ Deployment could not be verified, so this card was NOT marked as deployed.\n\n${describeFailureForHuman(res)}`));
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2361
|
+
if (res.pending && res.pendingVerification) {
|
|
2362
|
+
addPendingVerification(projectPath, { ...res.pendingVerification, provider });
|
|
2363
|
+
setCardState(projectPath, card.id, null);
|
|
2364
|
+
writeLock('waiting_ci', { sha: res.fullSha });
|
|
2365
|
+
await client.addProgress(card.id, provider, `${res.deployedNote} The card will move to **Done** once the pipeline passes.`);
|
|
2366
|
+
return false;
|
|
2367
|
+
}
|
|
2368
|
+
let uncommittedNote = '';
|
|
2369
|
+
if (!state?.files?.length) {
|
|
2370
|
+
const dirty = (await exec('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: projectPath, timeoutMs: 15000 })).stdout
|
|
2371
|
+
.split('\n')
|
|
2372
|
+
.filter((l) => l.trim()).length;
|
|
2373
|
+
if (dirty > 0) {
|
|
2374
|
+
uncommittedNote = ` Note: ${dirty} modified file(s) in the working tree are not committed, so they are NOT part of this deployment.`;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
await confirmDone(`${res.deployedNote}${uncommittedNote} Confirmed and moved card to **Done**.`, res.commitHash);
|
|
2378
|
+
sendDesktopNotification(`Agile Monster -> Production Confirmed`, `Deployed "${card.title}" (${res.commitHash})`);
|
|
1387
2379
|
return true;
|
|
1388
2380
|
}
|
|
1389
2381
|
catch (err) {
|
|
1390
|
-
|
|
1391
|
-
|
|
2382
|
+
log.error('deploy.approved_failed', { card: card.id, project: projectPath, error: errorMessage(err) });
|
|
2383
|
+
writeLock('failed', { error: String(err?.message || err).slice(0, 500) });
|
|
2384
|
+
return false;
|
|
2385
|
+
}
|
|
2386
|
+
finally {
|
|
2387
|
+
activeWork--;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* Deferred verification: deployments whose CI pipeline outlived our inline wait are re-checked on later
|
|
2392
|
+
* pulses, and the card is told the real outcome (instead of the old "still running after 8 minutes" failure).
|
|
2393
|
+
*/
|
|
2394
|
+
async function processPendingVerifications(client, projectPaths) {
|
|
2395
|
+
for (const projectPath of projectPaths) {
|
|
2396
|
+
if (!projectPath || !fs.existsSync(projectPath))
|
|
2397
|
+
continue;
|
|
2398
|
+
const memory = loadMemory(projectPath);
|
|
2399
|
+
if (memory.pending.length === 0)
|
|
2400
|
+
continue;
|
|
2401
|
+
const profile = resolveDeployProfile(projectPath, { memory });
|
|
2402
|
+
const chained = profile.workflows.some((w) => w.isDeploy && w.workflowRun);
|
|
2403
|
+
for (const p of memory.pending) {
|
|
2404
|
+
try {
|
|
2405
|
+
const ageMs = Date.now() - (Date.parse(p.started_at) || 0);
|
|
2406
|
+
const check = await checkCiOnce(projectPath, p.sha, p.workflow_names, chained);
|
|
2407
|
+
const short = p.sha.slice(0, 7);
|
|
2408
|
+
if (check.verdict === 'success') {
|
|
2409
|
+
removePendingVerification(projectPath, p.card_id);
|
|
2410
|
+
recordAttempt(projectPath, { card_id: p.card_id, method: 'github_actions', ok: true, commit: short });
|
|
2411
|
+
await client.addProgress(p.card_id, p.provider, `🚀 **Deployed**: GitHub Actions deployment passed for \`${short}\`${check.url ? ` (${check.url})` : ''}. The change is live.`);
|
|
2412
|
+
await client.reportDeployment({ card_id: p.card_id, project_path: projectPath, method: 'github_actions', ok: true, commit: short, source: 'verified' });
|
|
2413
|
+
}
|
|
2414
|
+
else if (check.verdict === 'failure') {
|
|
2415
|
+
removePendingVerification(projectPath, p.card_id);
|
|
2416
|
+
const detail = `GitHub Actions: ${check.reason || 'the deployment workflow failed'} for commit ${short}. ${check.url || ''}${check.logTail ? `\nFailing step output: ${check.logTail}` : ''}`;
|
|
2417
|
+
recordAttempt(projectPath, { card_id: p.card_id, method: 'github_actions', ok: false, kind: 'ci_failed', error: detail, commit: short });
|
|
2418
|
+
setCardState(projectPath, p.card_id, {
|
|
2419
|
+
stage: 'deploy_failed',
|
|
2420
|
+
files: [],
|
|
2421
|
+
summary: `Deployment of "${p.card_title}" (commit ${short}).`,
|
|
2422
|
+
verification_steps: `1. Check the live update for "${p.card_title}".`,
|
|
2423
|
+
diff: '',
|
|
2424
|
+
commit: short,
|
|
2425
|
+
at: new Date().toISOString(),
|
|
2426
|
+
attempts: 1,
|
|
2427
|
+
last_error: detail,
|
|
2428
|
+
});
|
|
2429
|
+
await client.askQuestion(p.card_id, p.provider, `The deployment I started for this card did not succeed, so the change is NOT live yet.\n\nWhat failed: ${detail}\n\nFix the failing step and reply **retry**, or tell me the right route with \`deploy: <method or command>\`.`);
|
|
2430
|
+
await client.reportDeployment({ card_id: p.card_id, project_path: projectPath, method: 'github_actions', ok: false, error: detail.slice(0, 480), error_kind: 'ci_failed', commit: short });
|
|
2431
|
+
}
|
|
2432
|
+
else if (ageMs > 90 * 60 * 1000) {
|
|
2433
|
+
removePendingVerification(projectPath, p.card_id);
|
|
2434
|
+
await client.addProgress(p.card_id, p.provider, `I could not confirm the GitHub Actions deployment for \`${short}\` within 90 minutes${p.run_url ? ` (${p.run_url})` : ''}. Please check the pipeline before approving.`);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
catch (err) {
|
|
2438
|
+
log.warn('deploy.pending_check_failed', { card: p.card_id, project: projectPath, error: errorMessage(err) });
|
|
2439
|
+
}
|
|
1392
2440
|
}
|
|
1393
|
-
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Machine-wide single-worker election. Only ONE process per machine may claim, edit, or deploy cards.
|
|
2445
|
+
* IDE-spawned MCP instances (Cursor, Claude Code, Antigravity, …) can be long-lived and run stale code,
|
|
2446
|
+
* so they defer to the background pulse daemon (role 'daemon' always wins) or to any live, fresh holder.
|
|
2447
|
+
*/
|
|
2448
|
+
const WORKER_LOCK_STALE_MS = 75 * 1000;
|
|
2449
|
+
function isPidAlive(pid) {
|
|
2450
|
+
try {
|
|
2451
|
+
process.kill(pid, 0);
|
|
2452
|
+
return true;
|
|
2453
|
+
}
|
|
2454
|
+
catch {
|
|
1394
2455
|
return false;
|
|
1395
2456
|
}
|
|
1396
2457
|
}
|
|
1397
|
-
export function
|
|
2458
|
+
export function acquireWorkerLock(role) {
|
|
2459
|
+
const lockPath = path.join(agileMonsterHome(), 'worker.lock.json');
|
|
2460
|
+
try {
|
|
2461
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
2462
|
+
let holder = null;
|
|
2463
|
+
if (fs.existsSync(lockPath)) {
|
|
2464
|
+
try {
|
|
2465
|
+
holder = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
2466
|
+
}
|
|
2467
|
+
catch {
|
|
2468
|
+
holder = null;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
const holderLive = !!holder?.pid &&
|
|
2472
|
+
holder.pid !== process.pid &&
|
|
2473
|
+
isPidAlive(holder.pid) &&
|
|
2474
|
+
Date.now() - (holder.heartbeat || 0) < WORKER_LOCK_STALE_MS;
|
|
2475
|
+
if (holderLive) {
|
|
2476
|
+
const holderIsDaemon = holder?.role === 'daemon';
|
|
2477
|
+
// A daemon only yields to another live daemon; stdio instances always yield to a live holder.
|
|
2478
|
+
if (role === 'stdio' || holderIsDaemon)
|
|
2479
|
+
return false;
|
|
2480
|
+
}
|
|
2481
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, role, version: MCP_SERVER_VERSION, heartbeat: Date.now() }), 'utf8');
|
|
2482
|
+
return true;
|
|
2483
|
+
}
|
|
2484
|
+
catch {
|
|
2485
|
+
return role === 'daemon';
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
export function startBuiltInPullPulse(server, client, intervalMs = 20000, role = 'stdio') {
|
|
1398
2489
|
const notifiedCardIds = new Set();
|
|
1399
2490
|
let updateTriggered = false;
|
|
1400
2491
|
let pulseCount = 0;
|
|
2492
|
+
let pulseRunning = false;
|
|
2493
|
+
let holdsWorkerLock = false;
|
|
2494
|
+
let journalsRecovered = false;
|
|
2495
|
+
let consecutiveFailures = 0;
|
|
1401
2496
|
const autoProvisionSavedBindings = (apiKey, bindings) => {
|
|
1402
2497
|
for (const b of bindings) {
|
|
1403
2498
|
const projPath = (b.project_path || '').trim();
|
|
1404
2499
|
if (!projPath || !fs.existsSync(projPath))
|
|
1405
2500
|
continue;
|
|
2501
|
+
// These files carry the personal API key: make sure no `git add -A` (ours or anyone's) can ever commit them
|
|
2502
|
+
ensureLocalGitExcludes(projPath);
|
|
1406
2503
|
const amConfigPath = path.join(projPath, '.agilemonster.json');
|
|
1407
2504
|
let needsWrite = true;
|
|
1408
2505
|
if (fs.existsSync(amConfigPath)) {
|
|
@@ -1450,11 +2547,21 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
|
|
|
1450
2547
|
if (!fs.existsSync(claudeMcp))
|
|
1451
2548
|
fs.writeFileSync(claudeMcp, mcpPayload, 'utf8');
|
|
1452
2549
|
}
|
|
1453
|
-
catch {
|
|
2550
|
+
catch (err) {
|
|
2551
|
+
log.warn('binding.provision_failed', { project: projPath, error: errorMessage(err) });
|
|
2552
|
+
}
|
|
1454
2553
|
}
|
|
1455
2554
|
}
|
|
1456
2555
|
};
|
|
1457
2556
|
const runPulse = async () => {
|
|
2557
|
+
// A pulse can run for minutes while it builds and deploys. Never start a second one on top of it —
|
|
2558
|
+
// just keep the worker lock fresh so no other process mistakes this one for dead and re-runs the same card.
|
|
2559
|
+
if (pulseRunning) {
|
|
2560
|
+
if (holdsWorkerLock)
|
|
2561
|
+
acquireWorkerLock(role);
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
pulseRunning = true;
|
|
1458
2565
|
try {
|
|
1459
2566
|
const config = client.loadProjectConfig();
|
|
1460
2567
|
const apiKey = client.getApiKey();
|
|
@@ -1471,6 +2578,10 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
|
|
|
1471
2578
|
discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
|
|
1472
2579
|
discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
|
|
1473
2580
|
});
|
|
2581
|
+
if (consecutiveFailures > 0) {
|
|
2582
|
+
log.info('pulse.recovered', { after_failures: consecutiveFailures });
|
|
2583
|
+
consecutiveFailures = 0;
|
|
2584
|
+
}
|
|
1474
2585
|
if (res.saved_bindings && res.saved_bindings.length > 0) {
|
|
1475
2586
|
autoProvisionSavedBindings(apiKey, res.saved_bindings);
|
|
1476
2587
|
}
|
|
@@ -1490,32 +2601,57 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
|
|
|
1490
2601
|
catch { }
|
|
1491
2602
|
}
|
|
1492
2603
|
}
|
|
1493
|
-
// Zero-touch autonomous execution
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
await executeQueuedCardAutonomously(client, qCard, targetPath);
|
|
1500
|
-
}
|
|
2604
|
+
// Zero-touch autonomous execution & deployment — ONLY the elected machine-wide worker may do this
|
|
2605
|
+
holdsWorkerLock = acquireWorkerLock(role);
|
|
2606
|
+
if (holdsWorkerLock && !journalsRecovered) {
|
|
2607
|
+
journalsRecovered = true;
|
|
2608
|
+
recoverEditJournals();
|
|
2609
|
+
pruneStaleLocks();
|
|
1501
2610
|
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
2611
|
+
if (holdsWorkerLock) {
|
|
2612
|
+
const boundQueued = res.all_bound_queued_cards || [];
|
|
2613
|
+
for (const qCard of boundQueued) {
|
|
2614
|
+
const targetPath = (qCard.bound_project_path || '').trim() ||
|
|
2615
|
+
(qCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
|
|
2616
|
+
if (targetPath && fs.existsSync(targetPath)) {
|
|
2617
|
+
await executeQueuedCardAutonomously(client, qCard, targetPath);
|
|
2618
|
+
}
|
|
2619
|
+
else if (targetPath) {
|
|
2620
|
+
log.warn('card.project_missing', { card: qCard.id, project: targetPath });
|
|
2621
|
+
}
|
|
1509
2622
|
}
|
|
2623
|
+
const boundDone = res.all_bound_done_cards || [];
|
|
2624
|
+
for (const dCard of boundDone) {
|
|
2625
|
+
const targetPath = (dCard.bound_project_path || '').trim() ||
|
|
2626
|
+
(dCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
|
|
2627
|
+
if (targetPath && fs.existsSync(targetPath)) {
|
|
2628
|
+
await deployApprovedCardIfNeeded(client, dCard, targetPath);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
const knownPaths = new Set([config.project_path || process.cwd()]);
|
|
2632
|
+
for (const b of res.saved_bindings || []) {
|
|
2633
|
+
if (b.project_path)
|
|
2634
|
+
knownPaths.add(b.project_path.trim());
|
|
2635
|
+
}
|
|
2636
|
+
await processPendingVerifications(client, Array.from(knownPaths));
|
|
1510
2637
|
}
|
|
1511
2638
|
if ((res.version?.force_update_requested || res.version?.update_available) &&
|
|
1512
|
-
!updateTriggered
|
|
2639
|
+
!updateTriggered &&
|
|
2640
|
+
role === 'daemon') {
|
|
1513
2641
|
updateTriggered = true;
|
|
1514
|
-
triggerBackgroundSelfUpdate();
|
|
2642
|
+
triggerBackgroundSelfUpdate(res.version?.latest_version);
|
|
1515
2643
|
}
|
|
1516
2644
|
}
|
|
1517
|
-
catch {
|
|
1518
|
-
//
|
|
2645
|
+
catch (err) {
|
|
2646
|
+
// Background pulses must never crash the host, but a silent failure is undiagnosable: log the first
|
|
2647
|
+
// one and then periodically while the outage lasts.
|
|
2648
|
+
consecutiveFailures++;
|
|
2649
|
+
if (consecutiveFailures === 1 || consecutiveFailures % 15 === 0) {
|
|
2650
|
+
log.error('pulse.failed', { error: errorMessage(err), consecutive: consecutiveFailures });
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
finally {
|
|
2654
|
+
pulseRunning = false;
|
|
1519
2655
|
}
|
|
1520
2656
|
};
|
|
1521
2657
|
setTimeout(runPulse, 2500).unref();
|
|
@@ -1524,33 +2660,49 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
|
|
|
1524
2660
|
return timer;
|
|
1525
2661
|
}
|
|
1526
2662
|
export async function runStdioServer() {
|
|
2663
|
+
configureLogger({ echo: false }); // stdout is the MCP protocol channel
|
|
2664
|
+
addWellKnownToolDirs();
|
|
2665
|
+
void ensureUserPath(); // asks the login shell in the background; never delays the MCP handshake
|
|
1527
2666
|
const client = new AgileMonsterClient();
|
|
1528
2667
|
const server = createServer(client);
|
|
1529
2668
|
const transport = new StdioServerTransport();
|
|
1530
2669
|
await server.connect(transport);
|
|
1531
|
-
startBuiltInPullPulse(server, client, 20000);
|
|
2670
|
+
startBuiltInPullPulse(server, client, 20000, 'stdio');
|
|
1532
2671
|
}
|
|
1533
2672
|
export async function runBackgroundPulseDaemon(intervalMs = 20000) {
|
|
2673
|
+
configureLogger({ echo: false });
|
|
2674
|
+
const addedPaths = await ensureUserPath();
|
|
1534
2675
|
const client = new AgileMonsterClient();
|
|
1535
2676
|
console.log(`[Agile Monster Pulse Daemon v${MCP_SERVER_VERSION}] Listening for queued agent tasks across all bound repositories (interval: ${intervalMs / 1000}s)...`);
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
2677
|
+
log.info('daemon.start', {
|
|
2678
|
+
version: MCP_SERVER_VERSION,
|
|
2679
|
+
cwd: process.cwd(),
|
|
2680
|
+
path_added: addedPaths,
|
|
2681
|
+
tools: { docker: which('docker'), gh: which('gh'), git: which('git') },
|
|
2682
|
+
});
|
|
2683
|
+
process.on('unhandledRejection', (reason) => log.error('daemon.unhandled_rejection', { error: errorMessage(reason) }));
|
|
2684
|
+
process.on('uncaughtException', (err) => {
|
|
2685
|
+
// State is undefined after an uncaught exception: record it and let launchd/systemd start a clean process.
|
|
2686
|
+
log.error('daemon.uncaught_exception', { error: errorMessage(err), stack: err instanceof Error ? err.stack : undefined });
|
|
2687
|
+
process.exit(1);
|
|
2688
|
+
});
|
|
2689
|
+
startBuiltInPullPulse(null, client, intervalMs, 'daemon');
|
|
2690
|
+
// Watch dist/server.js for updates so launchd reloads new code after a rebuild — but only between jobs,
|
|
2691
|
+
// never in the middle of an edit or a deployment.
|
|
2692
|
+
const distFile = fileURLToPath(import.meta.url);
|
|
2693
|
+
const initialMtime = fs.existsSync(distFile) ? fs.statSync(distFile).mtimeMs : 0;
|
|
2694
|
+
let reloadPending = false;
|
|
1540
2695
|
setInterval(() => {
|
|
1541
2696
|
try {
|
|
1542
|
-
if (
|
|
1543
|
-
const curMtime = fs.statSync(
|
|
1544
|
-
if (curMtime > initialMtime +
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
});
|
|
1549
|
-
child.unref();
|
|
1550
|
-
process.exit(0);
|
|
2697
|
+
if (!reloadPending && initialMtime > 0 && fs.existsSync(distFile)) {
|
|
2698
|
+
const curMtime = fs.statSync(distFile).mtimeMs;
|
|
2699
|
+
if (curMtime > initialMtime + 500) {
|
|
2700
|
+
reloadPending = true;
|
|
2701
|
+
console.log(`[Agile Monster Pulse Daemon] dist/server.js updated on disk. Reloading new code once idle...`);
|
|
2702
|
+
exitWhenIdle('dist-updated');
|
|
1551
2703
|
}
|
|
1552
2704
|
}
|
|
1553
2705
|
}
|
|
1554
2706
|
catch { }
|
|
1555
|
-
},
|
|
2707
|
+
}, 10000);
|
|
1556
2708
|
}
|