@agilemonster/mcp-server 1.0.16 → 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 +21 -4
- package/dist/client.js +119 -101
- 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 +16 -3
- package/dist/server.js +1427 -547
- package/dist/types.d.ts +29 -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,8 +965,31 @@ function syncIdeTaskRules(boardName, queuedCards) {
|
|
|
761
965
|
}
|
|
762
966
|
catch { }
|
|
763
967
|
}
|
|
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
|
+
}
|
|
764
982
|
function triggerBackgroundSelfUpdate(targetVersion) {
|
|
765
983
|
try {
|
|
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 });
|
|
766
993
|
// 1. Ensure local .cursor/mcp.json and .mcp.json use @latest so next IDE reload uses latest
|
|
767
994
|
const cwd = process.cwd();
|
|
768
995
|
for (const rel of ['.cursor/mcp.json', '.mcp.json', '.vscode/mcp.json']) {
|
|
@@ -778,7 +1005,7 @@ function triggerBackgroundSelfUpdate(targetVersion) {
|
|
|
778
1005
|
catch { }
|
|
779
1006
|
}
|
|
780
1007
|
}
|
|
781
|
-
// 2. Install @agilemonster/mcp-server@latest into global/npx cache
|
|
1008
|
+
// 2. Install @agilemonster/mcp-server@latest into global/npx cache, then exit once idle so launchd respawns the daemon
|
|
782
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'], {
|
|
783
1010
|
stdio: 'ignore',
|
|
784
1011
|
detached: true,
|
|
@@ -790,72 +1017,138 @@ function triggerBackgroundSelfUpdate(targetVersion) {
|
|
|
790
1017
|
agentName: 'Agile Monster Daemon',
|
|
791
1018
|
model: 'system',
|
|
792
1019
|
status: 'updated',
|
|
793
|
-
summary: `
|
|
1020
|
+
summary: `Updated @agilemonster/mcp-server from v${MCP_SERVER_VERSION} to v${target} and restarting the daemon.`,
|
|
794
1021
|
});
|
|
795
|
-
setTimeout(() =>
|
|
796
|
-
process.exit(0);
|
|
797
|
-
}, 1500).unref();
|
|
1022
|
+
setTimeout(() => exitWhenIdle('self-update'), 1500).unref();
|
|
798
1023
|
});
|
|
799
1024
|
child.unref();
|
|
800
1025
|
}
|
|
801
|
-
catch {
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
log.warn('daemon.self_update_failed', { error: errorMessage(err) });
|
|
1028
|
+
}
|
|
802
1029
|
}
|
|
803
|
-
|
|
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) {
|
|
804
1035
|
const sections = [];
|
|
805
|
-
|
|
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) {
|
|
806
1050
|
const docPath = path.join(projectPath, docName);
|
|
807
1051
|
if (fs.existsSync(docPath)) {
|
|
808
1052
|
try {
|
|
809
1053
|
const raw = fs.readFileSync(docPath, 'utf8').trim();
|
|
810
1054
|
if (raw.length > 0) {
|
|
811
|
-
sections.push(`=== ${docName} ===\n${raw.slice(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)}`);
|
|
812
1086
|
}
|
|
813
1087
|
}
|
|
814
1088
|
catch { }
|
|
815
1089
|
}
|
|
816
|
-
|
|
817
|
-
|
|
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 { }
|
|
818
1102
|
}
|
|
819
1103
|
try {
|
|
820
|
-
const recentCommits = execSync('git log -n
|
|
1104
|
+
const recentCommits = execSync('git log -n 8 --oneline', {
|
|
821
1105
|
cwd: projectPath,
|
|
822
1106
|
encoding: 'utf8',
|
|
823
1107
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1108
|
+
timeout: 5000,
|
|
824
1109
|
}).trim();
|
|
825
1110
|
if (recentCommits) {
|
|
826
1111
|
sections.push(`=== Recent Git Commits ===\n${recentCommits}`);
|
|
827
1112
|
}
|
|
828
1113
|
}
|
|
829
1114
|
catch { }
|
|
830
|
-
return sections.join('\n\n').slice(0,
|
|
1115
|
+
return sections.join('\n\n').slice(0, maxTotalChars);
|
|
831
1116
|
}
|
|
832
1117
|
const activeAutonomousCards = new Set();
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
'
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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 = [];
|
|
857
1150
|
const walk = (dir, depth) => {
|
|
858
|
-
if (depth >
|
|
1151
|
+
if (depth > 8 || found.length > 20000)
|
|
859
1152
|
return;
|
|
860
1153
|
let entries = [];
|
|
861
1154
|
try {
|
|
@@ -865,67 +1158,157 @@ function collectProjectSourceFiles(projectPath, keywords) {
|
|
|
865
1158
|
return;
|
|
866
1159
|
}
|
|
867
1160
|
for (const entry of entries) {
|
|
868
|
-
|
|
869
|
-
if (name.startsWith('.'))
|
|
1161
|
+
if (entry.name.startsWith('.'))
|
|
870
1162
|
continue;
|
|
871
|
-
|
|
872
|
-
continue;
|
|
873
|
-
const full = path.join(dir, name);
|
|
1163
|
+
const full = path.join(dir, entry.name);
|
|
874
1164
|
if (entry.isDirectory()) {
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
walk(full, depth + 1);
|
|
1165
|
+
if (!SKIP_DIRS.has(entry.name.toLowerCase()))
|
|
1166
|
+
walk(full, depth + 1);
|
|
878
1167
|
}
|
|
879
1168
|
else if (entry.isFile()) {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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)
|
|
884
1196
|
continue;
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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();
|
|
888
1287
|
for (const kw of keywords) {
|
|
889
|
-
if (kw.length >=
|
|
890
|
-
score +=
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
// Check file content for keyword hits on promising source files
|
|
894
|
-
if (ext === '.tsx' || ext === '.ts' || ext === '.dart' || ext === '.php' || ext === '.jsx') {
|
|
895
|
-
try {
|
|
896
|
-
const stat = fs.statSync(full);
|
|
897
|
-
if (stat.size < 120000) {
|
|
898
|
-
const contentLower = fs.readFileSync(full, 'utf8').toLowerCase();
|
|
899
|
-
for (const kw of keywords) {
|
|
900
|
-
if (kw.length >= 4 && contentLower.includes(kw)) {
|
|
901
|
-
score += 10;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
catch { }
|
|
1288
|
+
if (kw.length >= 4 && contentLower.includes(kw))
|
|
1289
|
+
c.score += 10;
|
|
907
1290
|
}
|
|
908
|
-
// Bonus score for UI components / screens AND backend controllers / models
|
|
909
|
-
if (ext === '.tsx' || ext === '.jsx' || ext === '.dart' || ext === '.vue') {
|
|
910
|
-
if (lowerRel.includes('component') || lowerRel.includes('screen') || lowerRel.includes('widget') || lowerRel.includes('modal') || lowerRel.includes('model')) {
|
|
911
|
-
score += 8;
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
if (ext === '.php' || ext === '.py' || ext === '.ts') {
|
|
915
|
-
if (lowerRel.includes('controller') || lowerRel.includes('service') || lowerRel.includes('route')) {
|
|
916
|
-
score += 10;
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
results.push({ rel, score });
|
|
920
1291
|
}
|
|
921
1292
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
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 { }
|
|
926
1309
|
}
|
|
927
1310
|
function getAgileMonsterLocksDir() {
|
|
928
|
-
const dir = path.join(
|
|
1311
|
+
const dir = path.join(agileMonsterHome(), 'locks');
|
|
929
1312
|
try {
|
|
930
1313
|
if (!fs.existsSync(dir)) {
|
|
931
1314
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -936,9 +1319,8 @@ function getAgileMonsterLocksDir() {
|
|
|
936
1319
|
}
|
|
937
1320
|
/**
|
|
938
1321
|
* Append a live, human-readable IDE activity entry to `<projectPath>/.agilemonster/ACTIVITY.md`
|
|
939
|
-
* (
|
|
940
|
-
*
|
|
941
|
-
* 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.
|
|
942
1324
|
*/
|
|
943
1325
|
function appendIdeActivityLog(projectPath, entry) {
|
|
944
1326
|
try {
|
|
@@ -946,13 +1328,7 @@ function appendIdeActivityLog(projectPath, entry) {
|
|
|
946
1328
|
if (!fs.existsSync(amDir)) {
|
|
947
1329
|
fs.mkdirSync(amDir, { recursive: true });
|
|
948
1330
|
}
|
|
949
|
-
|
|
950
|
-
if (fs.existsSync(gitExclude)) {
|
|
951
|
-
const exContent = fs.readFileSync(gitExclude, 'utf8');
|
|
952
|
-
if (!exContent.includes('.agilemonster/')) {
|
|
953
|
-
fs.appendFileSync(gitExclude, '\n.agilemonster/\n.agilemonster.json\n', 'utf8');
|
|
954
|
-
}
|
|
955
|
-
}
|
|
1331
|
+
ensureLocalGitExcludes(projectPath);
|
|
956
1332
|
const logPath = path.join(amDir, 'ACTIVITY.md');
|
|
957
1333
|
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
958
1334
|
const header = !fs.existsSync(logPath)
|
|
@@ -975,6 +1351,382 @@ function appendIdeActivityLog(projectPath, entry) {
|
|
|
975
1351
|
}
|
|
976
1352
|
catch { }
|
|
977
1353
|
}
|
|
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) {
|
|
1390
|
+
try {
|
|
1391
|
+
const files = {};
|
|
1392
|
+
for (const [abs, original] of originals.entries()) {
|
|
1393
|
+
let current = original;
|
|
1394
|
+
try {
|
|
1395
|
+
current = fs.readFileSync(abs, 'utf8');
|
|
1396
|
+
}
|
|
1397
|
+
catch { }
|
|
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;
|
|
1430
|
+
}
|
|
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 { }
|
|
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 });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
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)}`);
|
|
1521
|
+
return false;
|
|
1522
|
+
}
|
|
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
|
+
}
|
|
978
1730
|
export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
979
1731
|
if (!projectPath || !fs.existsSync(projectPath))
|
|
980
1732
|
return false;
|
|
@@ -1001,23 +1753,42 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1001
1753
|
return false;
|
|
1002
1754
|
}
|
|
1003
1755
|
}
|
|
1004
|
-
|
|
1005
|
-
card_id: card.id,
|
|
1006
|
-
card_updated_at: cardUpdatedAt,
|
|
1007
|
-
status: 'running',
|
|
1008
|
-
started_at: new Date().toISOString(),
|
|
1009
|
-
}), 'utf8');
|
|
1756
|
+
writeTaskLock(lockFile, card, 'running');
|
|
1010
1757
|
}
|
|
1011
1758
|
catch {
|
|
1012
1759
|
return false;
|
|
1013
1760
|
}
|
|
1014
1761
|
activeAutonomousCards.add(card.id);
|
|
1762
|
+
activeWork++;
|
|
1015
1763
|
const provider = card.agent_provider || 'cursor';
|
|
1016
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 });
|
|
1017
1769
|
try {
|
|
1018
1770
|
// 1. Claim the card (automatically moves it from ToDo -> In Progress on the board!)
|
|
1019
1771
|
await client.claimTask(card.id, provider, `auto-pulse-${Date.now()}`, card.board_id);
|
|
1020
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) });
|
|
1021
1792
|
await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...`);
|
|
1022
1793
|
// 2. Build keyword list from card title + description and collect project source files
|
|
1023
1794
|
const rawText = `${card.title || ''} ${card.description || ''}`.toLowerCase();
|
|
@@ -1027,18 +1798,22 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1027
1798
|
.filter((w) => w.length >= 3 && !stopWords.has(w))));
|
|
1028
1799
|
const fileIndex = collectProjectSourceFiles(projectPath, keywords);
|
|
1029
1800
|
if (fileIndex.length === 0) {
|
|
1030
|
-
|
|
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.`);
|
|
1031
1803
|
return false;
|
|
1032
1804
|
}
|
|
1033
1805
|
// 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
|
|
1034
|
-
const projectContext = readProjectInstructionsContext(projectPath);
|
|
1806
|
+
const projectContext = readProjectInstructionsContext(projectPath, profile);
|
|
1035
1807
|
const selectionRes = await client.autonomousStep({
|
|
1036
1808
|
card_id: card.id,
|
|
1037
1809
|
action: 'select_files',
|
|
1038
1810
|
file_index: fileIndex,
|
|
1039
1811
|
project_context: projectContext,
|
|
1040
1812
|
});
|
|
1041
|
-
|
|
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);
|
|
1042
1817
|
if (selectedFiles.length === 0) {
|
|
1043
1818
|
selectedFiles.push(...fileIndex.slice(0, 4));
|
|
1044
1819
|
}
|
|
@@ -1061,12 +1836,17 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1061
1836
|
const full = path.join(projectPath, rel);
|
|
1062
1837
|
if (fs.existsSync(full)) {
|
|
1063
1838
|
try {
|
|
1064
|
-
|
|
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');
|
|
1065
1843
|
}
|
|
1066
1844
|
catch { }
|
|
1067
1845
|
}
|
|
1068
1846
|
}
|
|
1069
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?');
|
|
1070
1850
|
return false;
|
|
1071
1851
|
}
|
|
1072
1852
|
await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
|
|
@@ -1081,11 +1861,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1081
1861
|
});
|
|
1082
1862
|
// Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
|
|
1083
1863
|
if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
|
|
1084
|
-
|
|
1085
|
-
if (fs.existsSync(lockFile))
|
|
1086
|
-
fs.unlinkSync(lockFile);
|
|
1087
|
-
}
|
|
1088
|
-
catch { }
|
|
1864
|
+
removeTaskLock(lockFile);
|
|
1089
1865
|
appendIdeActivityLog(projectPath, {
|
|
1090
1866
|
cardId: card.id,
|
|
1091
1867
|
cardTitle: card.title,
|
|
@@ -1098,116 +1874,148 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1098
1874
|
await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
|
|
1099
1875
|
return false;
|
|
1100
1876
|
}
|
|
1101
|
-
|
|
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) => {
|
|
1102
1885
|
const modFiles = [];
|
|
1886
|
+
const failed = [];
|
|
1103
1887
|
for (const edit of editsList) {
|
|
1104
|
-
const relPath = edit.file || edit.path;
|
|
1105
|
-
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`);
|
|
1106
1891
|
continue;
|
|
1107
|
-
|
|
1108
|
-
if (!
|
|
1892
|
+
}
|
|
1893
|
+
if (!editableFiles.has(relPath)) {
|
|
1894
|
+
failed.push(`${relPath}: not one of the files provided`);
|
|
1109
1895
|
continue;
|
|
1110
|
-
|
|
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`);
|
|
1111
1905
|
continue;
|
|
1906
|
+
}
|
|
1112
1907
|
const currentContent = fs.readFileSync(fullPath, 'utf8');
|
|
1113
|
-
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))
|
|
1114
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
|
+
}
|
|
1115
1936
|
}
|
|
1116
|
-
if (
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
|
|
1124
|
-
if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
|
|
1125
|
-
continue;
|
|
1126
|
-
}
|
|
1127
|
-
fs.writeFileSync(fullPath, updatedContent, 'utf8');
|
|
1128
|
-
if (relPath.endsWith('.dart')) {
|
|
1129
|
-
try {
|
|
1130
|
-
execSync(`dart analyze "${fullPath}"`, {
|
|
1131
|
-
cwd: projectPath,
|
|
1132
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1133
|
-
timeout: 25000,
|
|
1134
|
-
});
|
|
1135
|
-
}
|
|
1136
|
-
catch (analyzeErr) {
|
|
1137
|
-
const out = String(analyzeErr?.stdout || '') + String(analyzeErr?.stderr || '');
|
|
1138
|
-
if (/\berror\s+[•-]/i.test(out)) {
|
|
1139
|
-
fs.writeFileSync(fullPath, currentContent, 'utf8');
|
|
1140
|
-
continue;
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
else if (relPath.endsWith('.php')) {
|
|
1145
|
-
try {
|
|
1146
|
-
execSync(`php -l "${fullPath}"`, {
|
|
1147
|
-
cwd: projectPath,
|
|
1148
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1149
|
-
timeout: 10000,
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
catch {
|
|
1153
|
-
fs.writeFileSync(fullPath, currentContent, 'utf8');
|
|
1154
|
-
continue;
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
if (!modFiles.includes(relPath)) {
|
|
1158
|
-
modFiles.push(relPath);
|
|
1159
|
-
}
|
|
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;
|
|
1160
1944
|
}
|
|
1161
1945
|
}
|
|
1946
|
+
if (!modFiles.includes(relPath))
|
|
1947
|
+
modFiles.push(relPath);
|
|
1162
1948
|
}
|
|
1163
|
-
return modFiles;
|
|
1949
|
+
return { modFiles, failed };
|
|
1164
1950
|
};
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
// Pre-submit frontend build verification gate for root AND monorepo subdirectories (frontend/, web/, client/)
|
|
1168
|
-
const verifyFrontendBuilds = () => {
|
|
1169
|
-
const hasFrontendEdits = modifiedFiles.some((f) => /\.(tsx?|jsx?|vue|css)$/i.test(f));
|
|
1170
|
-
if (!hasFrontendEdits)
|
|
1171
|
-
return { ok: true };
|
|
1172
|
-
const candidateDirs = [
|
|
1173
|
-
projectPath,
|
|
1174
|
-
path.join(projectPath, 'frontend'),
|
|
1175
|
-
path.join(projectPath, 'web'),
|
|
1176
|
-
path.join(projectPath, 'client'),
|
|
1177
|
-
];
|
|
1178
|
-
for (const dir of candidateDirs) {
|
|
1179
|
-
const pkgJsonPath = path.join(dir, 'package.json');
|
|
1180
|
-
if (!fs.existsSync(pkgJsonPath) || !fs.existsSync(path.join(dir, 'node_modules')))
|
|
1181
|
-
continue;
|
|
1951
|
+
const revertAll = () => {
|
|
1952
|
+
for (const [fPath, orig] of originalContents.entries()) {
|
|
1182
1953
|
try {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1954
|
+
fs.writeFileSync(fPath, orig, 'utf8');
|
|
1955
|
+
}
|
|
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.` };
|
|
1194
2008
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
return { ok: false, errorOutput: errOut.slice(-1500) };
|
|
2009
|
+
if (!res.ok) {
|
|
2010
|
+
return { ok: false, errorOutput: `${res.stdout}\n${res.stderr}`.trim().slice(-1500) };
|
|
1198
2011
|
}
|
|
1199
2012
|
}
|
|
1200
2013
|
return { ok: true };
|
|
1201
2014
|
};
|
|
1202
|
-
let buildCheck = verifyFrontendBuilds();
|
|
2015
|
+
let buildCheck = await verifyFrontendBuilds();
|
|
1203
2016
|
if (!buildCheck.ok) {
|
|
1204
2017
|
// Revert first pass, attach compiler error context, and do 1 self-correction pass
|
|
1205
|
-
|
|
1206
|
-
try {
|
|
1207
|
-
fs.writeFileSync(fPath, orig, 'utf8');
|
|
1208
|
-
}
|
|
1209
|
-
catch { }
|
|
1210
|
-
}
|
|
2018
|
+
revertAll();
|
|
1211
2019
|
modifiedFiles.length = 0;
|
|
1212
2020
|
const retryFiles = {
|
|
1213
2021
|
...filesPayload,
|
|
@@ -1219,329 +2027,176 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
1219
2027
|
files: retryFiles,
|
|
1220
2028
|
project_context: projectContext,
|
|
1221
2029
|
});
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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;
|
|
1231
2041
|
}
|
|
1232
|
-
modifiedFiles.length = 0;
|
|
1233
2042
|
}
|
|
1234
2043
|
}
|
|
1235
|
-
if (modifiedFiles.length
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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',
|
|
2096
|
+
cardId: card.id,
|
|
2097
|
+
files: commitFiles,
|
|
2098
|
+
bound: boundProfileOf(card),
|
|
2099
|
+
});
|
|
2100
|
+
await reportOutcome(client, card, projectPath, deployInfo);
|
|
1252
2101
|
if (!deployInfo.ok) {
|
|
1253
|
-
// Never claim success: keep card
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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);
|
|
1260
2109
|
appendIdeActivityLog(projectPath, {
|
|
1261
2110
|
cardId: card.id,
|
|
1262
2111
|
cardTitle: card.title,
|
|
1263
2112
|
agentName: workerLabel,
|
|
1264
2113
|
model: card.agent_model || 'auto',
|
|
1265
2114
|
status: 'deploy_failed',
|
|
1266
|
-
summary: `Code change was made but deployment could not be verified: ${
|
|
2115
|
+
summary: `Code change was made but deployment could not be verified: ${deployInfo.errorDetail.slice(0, 700)}`,
|
|
1267
2116
|
files: modifiedFiles,
|
|
1268
2117
|
diff: rawDiff,
|
|
1269
2118
|
});
|
|
1270
|
-
|
|
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)}`);
|
|
1271
2123
|
return false;
|
|
1272
2124
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
const cleanPlainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() +
|
|
1276
|
-
(deployInfo.deployedNote ? `\n\n${deployInfo.deployedNote}` : '');
|
|
1277
|
-
const verificationSteps = editsRes.verification_steps ||
|
|
1278
|
-
`1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`;
|
|
1279
|
-
const logLines = [
|
|
1280
|
-
`Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
|
|
1281
|
-
`Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
|
|
1282
|
-
`Selected files: ${Object.keys(filesPayload).join(', ')}`,
|
|
1283
|
-
`Modified & verified files: ${modifiedFiles.join(', ')}`,
|
|
1284
|
-
...(deployInfo.deployedNote ? [`Deployment: ${deployInfo.deployedNote}`] : []),
|
|
1285
|
-
`Summary: ${cleanPlainSummary}`,
|
|
1286
|
-
];
|
|
1287
|
-
appendIdeActivityLog(projectPath, {
|
|
1288
|
-
cardId: card.id,
|
|
1289
|
-
cardTitle: card.title,
|
|
1290
|
-
agentName: workerLabel,
|
|
1291
|
-
model: card.agent_model || 'auto',
|
|
1292
|
-
status: 'review',
|
|
1293
|
-
summary: cleanPlainSummary,
|
|
1294
|
-
files: modifiedFiles,
|
|
1295
|
-
diff: rawDiff,
|
|
1296
|
-
});
|
|
1297
|
-
await client.submitReview(card.id, provider, cleanPlainSummary, verificationSteps, undefined, undefined, {
|
|
1298
|
-
diffText: rawDiff,
|
|
1299
|
-
logText: logLines.join('\n'),
|
|
1300
|
-
filesModified: modifiedFiles.join(', '),
|
|
1301
|
-
});
|
|
1302
|
-
try {
|
|
1303
|
-
fs.writeFileSync(lockFile, JSON.stringify({
|
|
1304
|
-
card_id: card.id,
|
|
1305
|
-
card_updated_at: cardUpdatedAt,
|
|
1306
|
-
status: 'completed',
|
|
1307
|
-
completed_at: new Date().toISOString(),
|
|
1308
|
-
}), 'utf8');
|
|
2125
|
+
if (deployInfo.pending && deployInfo.pendingVerification) {
|
|
2126
|
+
addPendingVerification(projectPath, { ...deployInfo.pendingVerification, provider });
|
|
1309
2127
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
return true;
|
|
2128
|
+
setCardState(projectPath, card.id, null);
|
|
2129
|
+
deployedNote = deployInfo.deployedNote;
|
|
1313
2130
|
}
|
|
1314
2131
|
else {
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
const buildNote = buildCheck && !buildCheck.ok && buildCheck.errorOutput
|
|
1321
|
-
? `\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)}`
|
|
1322
|
-
: '';
|
|
1323
|
-
await client.askQuestion(card.id, provider, `I inspected ${Object.keys(filesPayload)
|
|
1324
|
-
.map((f) => `\`${f}\``)
|
|
1325
|
-
.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?`);
|
|
1326
|
-
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.`;
|
|
1327
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;
|
|
1328
2167
|
}
|
|
1329
2168
|
catch (err) {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
+
}
|
|
1333
2179
|
}
|
|
1334
|
-
|
|
2180
|
+
if (!editsHandedOff)
|
|
2181
|
+
clearEditJournal(card.id);
|
|
2182
|
+
log.error('card.failed', { card: card.id, project: projectPath, error: errorMessage(err), reverted: rolledBack });
|
|
1335
2183
|
try {
|
|
1336
|
-
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)' : ''}`);
|
|
1337
2185
|
}
|
|
1338
2186
|
catch { }
|
|
1339
2187
|
return false;
|
|
1340
2188
|
}
|
|
1341
2189
|
finally {
|
|
1342
2190
|
activeAutonomousCards.delete(card.id);
|
|
2191
|
+
activeWork--;
|
|
1343
2192
|
}
|
|
1344
2193
|
}
|
|
1345
2194
|
/**
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
* - `docker_compose`: runs the deploy command and checks `docker compose ps` for running containers.
|
|
1352
|
-
* 3. If a `live_url` is known, performs an HTTP smoke check (must return < 400).
|
|
1353
|
-
* Returns ok=false with a plain-English `errorDetail` whenever deployment cannot be proven.
|
|
1354
|
-
*/
|
|
1355
|
-
async function runProjectDeploymentPipeline(projectPath, rawTitle, shortId, forceEmptyCommit = false, profile) {
|
|
1356
|
-
const cleanTitle = (rawTitle || 'update').replace(/["`$\\\r\n]+/g, ' ').trim();
|
|
1357
|
-
let branch = 'main';
|
|
1358
|
-
let commitHash = '';
|
|
1359
|
-
let fullSha = '';
|
|
1360
|
-
const actions = [];
|
|
1361
|
-
const run = (cmd, timeout = 45000) => execSync(cmd, { cwd: projectPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout }).trim();
|
|
1362
|
-
const errText = (e) => (String(e?.stderr || '') + '\n' + String(e?.stdout || '') + '\n' + String(e?.message || ''))
|
|
1363
|
-
.split('\n')
|
|
1364
|
-
.map((l) => l.trim())
|
|
1365
|
-
.filter((l) => l.length > 0)
|
|
1366
|
-
.slice(-6)
|
|
1367
|
-
.join(' | ')
|
|
1368
|
-
.slice(0, 700);
|
|
1369
|
-
const detected = AgileMonsterClient.inspectProjectProfile(projectPath, path.basename(projectPath));
|
|
1370
|
-
const deployMethod = (profile?.deploy_method || detected.deploy_method || 'git_push').toLowerCase();
|
|
1371
|
-
const deployCommand = profile?.deploy_command || detected.deploy_command || '';
|
|
1372
|
-
const liveUrl = profile?.live_url || detected.live_url || '';
|
|
1373
|
-
// Step 1: commit & push
|
|
1374
|
-
try {
|
|
1375
|
-
branch = run('git rev-parse --abbrev-ref HEAD') || 'main';
|
|
1376
|
-
const statusOut = run('git status --porcelain -uno');
|
|
1377
|
-
if (statusOut.length > 0) {
|
|
1378
|
-
run('git add -u');
|
|
1379
|
-
run(`git commit -m "feat: ${cleanTitle} [AM-${shortId}]"`);
|
|
1380
|
-
}
|
|
1381
|
-
else if (forceEmptyCommit) {
|
|
1382
|
-
run(`git commit --allow-empty -m "chore: trigger redeploy [AM-${shortId}]"`);
|
|
1383
|
-
}
|
|
1384
|
-
fullSha = run('git rev-parse HEAD');
|
|
1385
|
-
commitHash = fullSha.slice(0, 7);
|
|
1386
|
-
if (fs.existsSync(path.join(projectPath, '.git')) && run('git remote').length > 0) {
|
|
1387
|
-
try {
|
|
1388
|
-
run(`git push origin ${branch}`, 60000);
|
|
1389
|
-
actions.push(`pushed \`${commitHash}\` to \`origin/${branch}\``);
|
|
1390
|
-
}
|
|
1391
|
-
catch (pushErr) {
|
|
1392
|
-
return {
|
|
1393
|
-
ok: false,
|
|
1394
|
-
commitHash,
|
|
1395
|
-
branch,
|
|
1396
|
-
deployedNote: '',
|
|
1397
|
-
errorDetail: `git push to origin/${branch} failed (check credentials or branch protection): ${errText(pushErr)}`,
|
|
1398
|
-
};
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
catch (gitErr) {
|
|
1403
|
-
return { ok: false, commitHash, branch, deployedNote: '', errorDetail: `Git commit failed: ${errText(gitErr)}` };
|
|
1404
|
-
}
|
|
1405
|
-
// Step 2: run & verify the deploy method
|
|
1406
|
-
try {
|
|
1407
|
-
if (deployMethod === 'deploy_script') {
|
|
1408
|
-
const cmd = deployCommand && !deployCommand.includes('(')
|
|
1409
|
-
? deployCommand
|
|
1410
|
-
: './deploy/deploy.sh';
|
|
1411
|
-
const scriptPath = cmd.split(' ')[0];
|
|
1412
|
-
const args = cmd.split(' ').slice(1).join(' ');
|
|
1413
|
-
try {
|
|
1414
|
-
run(`bash "${path.resolve(projectPath, scriptPath)}" ${args}`, 300000);
|
|
1415
|
-
}
|
|
1416
|
-
catch (dErr) {
|
|
1417
|
-
return {
|
|
1418
|
-
ok: false,
|
|
1419
|
-
commitHash,
|
|
1420
|
-
branch,
|
|
1421
|
-
deployedNote: '',
|
|
1422
|
-
errorDetail: `Deploy script \`${cmd}\` exited with an error: ${errText(dErr)}`,
|
|
1423
|
-
};
|
|
1424
|
-
}
|
|
1425
|
-
actions.push(`deployed via \`${cmd}\` (exit 0)`);
|
|
1426
|
-
}
|
|
1427
|
-
else if (deployMethod === 'github_actions') {
|
|
1428
|
-
let hasGh = false;
|
|
1429
|
-
try {
|
|
1430
|
-
run('gh --version', 5000);
|
|
1431
|
-
hasGh = true;
|
|
1432
|
-
}
|
|
1433
|
-
catch { }
|
|
1434
|
-
if (!hasGh) {
|
|
1435
|
-
actions.push('triggered GitHub Actions (could not verify: `gh` CLI not installed)');
|
|
1436
|
-
}
|
|
1437
|
-
else {
|
|
1438
|
-
const deadline = Date.now() + 8 * 60 * 1000;
|
|
1439
|
-
let verdict = 'none';
|
|
1440
|
-
let failedName = '';
|
|
1441
|
-
let runUrl = '';
|
|
1442
|
-
// Give GitHub a moment to register the workflow run
|
|
1443
|
-
await new Promise((r) => setTimeout(r, 8000));
|
|
1444
|
-
while (Date.now() < deadline) {
|
|
1445
|
-
let runs = [];
|
|
1446
|
-
try {
|
|
1447
|
-
runs = JSON.parse(run(`gh run list --commit ${fullSha} --json status,conclusion,name,url --limit 10`, 20000) || '[]');
|
|
1448
|
-
}
|
|
1449
|
-
catch { }
|
|
1450
|
-
if (runs.length === 0) {
|
|
1451
|
-
verdict = 'none';
|
|
1452
|
-
}
|
|
1453
|
-
else if (runs.some((r) => r.status !== 'completed')) {
|
|
1454
|
-
verdict = 'pending';
|
|
1455
|
-
}
|
|
1456
|
-
else {
|
|
1457
|
-
const failed = runs.find((r) => r.conclusion !== 'success' && r.conclusion !== 'skipped');
|
|
1458
|
-
verdict = failed ? 'failure' : 'success';
|
|
1459
|
-
failedName = failed?.name || '';
|
|
1460
|
-
runUrl = (failed || runs[0])?.url || '';
|
|
1461
|
-
break;
|
|
1462
|
-
}
|
|
1463
|
-
if (verdict === 'none' && Date.now() > deadline - 7 * 60 * 1000)
|
|
1464
|
-
break; // no workflow for this commit after ~1 min
|
|
1465
|
-
await new Promise((r) => setTimeout(r, 15000));
|
|
1466
|
-
}
|
|
1467
|
-
if (verdict === 'failure') {
|
|
1468
|
-
return {
|
|
1469
|
-
ok: false,
|
|
1470
|
-
commitHash,
|
|
1471
|
-
branch,
|
|
1472
|
-
deployedNote: '',
|
|
1473
|
-
errorDetail: `GitHub Actions workflow "${failedName}" failed for commit ${commitHash}. ${runUrl}`.trim(),
|
|
1474
|
-
};
|
|
1475
|
-
}
|
|
1476
|
-
if (verdict === 'pending') {
|
|
1477
|
-
return {
|
|
1478
|
-
ok: false,
|
|
1479
|
-
commitHash,
|
|
1480
|
-
branch,
|
|
1481
|
-
deployedNote: '',
|
|
1482
|
-
errorDetail: `GitHub Actions for commit ${commitHash} was still running after 8 minutes. ${runUrl}`.trim(),
|
|
1483
|
-
};
|
|
1484
|
-
}
|
|
1485
|
-
actions.push(verdict === 'success'
|
|
1486
|
-
? `GitHub Actions passed for \`${commitHash}\``
|
|
1487
|
-
: 'pushed (no GitHub Actions workflow ran for this commit)');
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
else if (deployMethod === 'docker_compose') {
|
|
1491
|
-
const cmd = deployCommand || 'docker compose up -d --build';
|
|
1492
|
-
try {
|
|
1493
|
-
run(cmd, 600000);
|
|
1494
|
-
const ps = run('docker compose ps --format json', 20000);
|
|
1495
|
-
if (/"State"\s*:\s*"(exited|dead|restarting)"/i.test(ps)) {
|
|
1496
|
-
return { ok: false, commitHash, branch, deployedNote: '', errorDetail: 'A Docker container is not running after deploy (exited/restarting).' };
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
catch (dErr) {
|
|
1500
|
-
return { ok: false, commitHash, branch, deployedNote: '', errorDetail: `Docker deploy failed: ${errText(dErr)}` };
|
|
1501
|
-
}
|
|
1502
|
-
actions.push('Docker containers rebuilt and running');
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
catch (anyErr) {
|
|
1506
|
-
return { ok: false, commitHash, branch, deployedNote: '', errorDetail: errText(anyErr) };
|
|
1507
|
-
}
|
|
1508
|
-
// Step 3: live URL smoke check
|
|
1509
|
-
if (liveUrl && /^https?:\/\//i.test(liveUrl)) {
|
|
1510
|
-
let status = 0;
|
|
1511
|
-
for (let attempt = 0; attempt < 3 && (status === 0 || status >= 400); attempt++) {
|
|
1512
|
-
try {
|
|
1513
|
-
const ctrl = new AbortController();
|
|
1514
|
-
const t = setTimeout(() => ctrl.abort(), 15000);
|
|
1515
|
-
const res = await fetch(liveUrl, { method: 'GET', redirect: 'follow', signal: ctrl.signal });
|
|
1516
|
-
clearTimeout(t);
|
|
1517
|
-
status = res.status;
|
|
1518
|
-
}
|
|
1519
|
-
catch {
|
|
1520
|
-
status = 0;
|
|
1521
|
-
}
|
|
1522
|
-
if (status === 0 || status >= 400)
|
|
1523
|
-
await new Promise((r) => setTimeout(r, 5000));
|
|
1524
|
-
}
|
|
1525
|
-
if (status === 0 || status >= 400) {
|
|
1526
|
-
return {
|
|
1527
|
-
ok: false,
|
|
1528
|
-
commitHash,
|
|
1529
|
-
branch,
|
|
1530
|
-
deployedNote: '',
|
|
1531
|
-
errorDetail: `Deployment finished, but ${liveUrl} returned ${status === 0 ? 'no response' : `HTTP ${status}`}.`,
|
|
1532
|
-
};
|
|
1533
|
-
}
|
|
1534
|
-
actions.push(`verified live at ${liveUrl} (HTTP ${status})`);
|
|
1535
|
-
}
|
|
1536
|
-
const deployedNote = actions.length > 0
|
|
1537
|
-
? `🚀 **Deployed**: ${actions.join(', ')}.`
|
|
1538
|
-
: `🚀 **Deployed**: commit \`${commitHash}\` on \`${branch}\`.`;
|
|
1539
|
-
return { ok: true, commitHash, branch, deployedNote, errorDetail: '' };
|
|
1540
|
-
}
|
|
1541
|
-
/**
|
|
1542
|
-
* Deploy a card the human approved into Done (or flagged as `deploying`).
|
|
1543
|
-
* Idempotent: keyed on card ID only (never on `updated_at`, which changes whenever we post a comment),
|
|
1544
|
-
* so a card is deployed at most once unless the human explicitly re-triggers it into `deploying`.
|
|
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.
|
|
1545
2200
|
*/
|
|
1546
2201
|
export async function deployApprovedCardIfNeeded(client, card, projectPath) {
|
|
1547
2202
|
if (!projectPath || !fs.existsSync(projectPath))
|
|
@@ -1557,71 +2212,233 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
|
|
|
1557
2212
|
existing = { status: 'deployed' }; // unreadable lock: fail safe (never loop)
|
|
1558
2213
|
}
|
|
1559
2214
|
}
|
|
1560
|
-
const
|
|
2215
|
+
const awaitingDeploy = card.agent_status === 'deploying';
|
|
2216
|
+
const priorAttempts = Number(existing?.attempts || 0);
|
|
1561
2217
|
if (existing) {
|
|
1562
|
-
// Already handled and
|
|
1563
|
-
if (!
|
|
2218
|
+
// Already handled and nobody is waiting on a deployment
|
|
2219
|
+
if (!awaitingDeploy)
|
|
1564
2220
|
return false;
|
|
1565
|
-
|
|
1566
|
-
if (existing.
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
+
}
|
|
1570
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).
|
|
1571
2244
|
}
|
|
1572
|
-
// Cards that were already deployed during the Review stage only need the Done confirmation, not another deploy
|
|
1573
2245
|
const deployStage = (card.deploy_stage || 'review').toLowerCase();
|
|
1574
2246
|
const provider = card.agent_provider || 'cursor';
|
|
1575
|
-
|
|
1576
|
-
const isContainerRestartCard =
|
|
2247
|
+
// Only a card that literally asks for a redeploy gets one; ordinary cards never trigger a second deployment
|
|
2248
|
+
const isContainerRestartCard = isRedeployOnlyCard(card);
|
|
1577
2249
|
const writeLock = (status, extra = {}) => {
|
|
1578
2250
|
try {
|
|
1579
2251
|
fs.writeFileSync(deployLock, JSON.stringify({
|
|
1580
2252
|
card_id: card.id,
|
|
1581
2253
|
status,
|
|
1582
2254
|
title: card.title,
|
|
1583
|
-
redeploy_of:
|
|
2255
|
+
redeploy_of: awaitingDeploy ? card.updated_at : null,
|
|
1584
2256
|
started_at: new Date().toISOString(),
|
|
2257
|
+
attempts: status === 'failed' ? priorAttempts + 1 : priorAttempts,
|
|
1585
2258
|
...extra,
|
|
1586
2259
|
}), 'utf8');
|
|
1587
2260
|
}
|
|
1588
2261
|
catch { }
|
|
1589
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);
|
|
2267
|
+
appendIdeActivityLog(projectPath, {
|
|
2268
|
+
cardId: card.id,
|
|
2269
|
+
cardTitle: card.title,
|
|
2270
|
+
agentName: card.agent_display_name || provider,
|
|
2271
|
+
model: card.agent_model || 'auto',
|
|
2272
|
+
status: 'done',
|
|
2273
|
+
summary: msg,
|
|
2274
|
+
});
|
|
2275
|
+
};
|
|
2276
|
+
activeWork++;
|
|
1590
2277
|
writeLock('running');
|
|
1591
2278
|
try {
|
|
1592
2279
|
if (deployStage === 'manual') {
|
|
2280
|
+
// A human ships this project: nothing is waiting on us any more
|
|
2281
|
+
setCardState(projectPath, card.id, null);
|
|
1593
2282
|
writeLock('deployed', { note: 'manual deploy stage' });
|
|
1594
2283
|
return false;
|
|
1595
2284
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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 });
|
|
1599
2310
|
return false;
|
|
1600
2311
|
}
|
|
1601
|
-
|
|
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);
|
|
1602
2341
|
if (!res.ok) {
|
|
1603
2342
|
writeLock('failed', { error: res.errorDetail });
|
|
1604
|
-
|
|
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)}`));
|
|
1605
2359
|
return false;
|
|
1606
2360
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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);
|
|
1618
2378
|
sendDesktopNotification(`Agile Monster -> Production Confirmed`, `Deployed "${card.title}" (${res.commitHash})`);
|
|
1619
2379
|
return true;
|
|
1620
2380
|
}
|
|
1621
2381
|
catch (err) {
|
|
2382
|
+
log.error('deploy.approved_failed', { card: card.id, project: projectPath, error: errorMessage(err) });
|
|
1622
2383
|
writeLock('failed', { error: String(err?.message || err).slice(0, 500) });
|
|
1623
2384
|
return false;
|
|
1624
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
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
1625
2442
|
}
|
|
1626
2443
|
/**
|
|
1627
2444
|
* Machine-wide single-worker election. Only ONE process per machine may claim, edit, or deploy cards.
|
|
@@ -1639,7 +2456,7 @@ function isPidAlive(pid) {
|
|
|
1639
2456
|
}
|
|
1640
2457
|
}
|
|
1641
2458
|
export function acquireWorkerLock(role) {
|
|
1642
|
-
const lockPath = path.join(
|
|
2459
|
+
const lockPath = path.join(agileMonsterHome(), 'worker.lock.json');
|
|
1643
2460
|
try {
|
|
1644
2461
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
1645
2462
|
let holder = null;
|
|
@@ -1672,11 +2489,17 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1672
2489
|
const notifiedCardIds = new Set();
|
|
1673
2490
|
let updateTriggered = false;
|
|
1674
2491
|
let pulseCount = 0;
|
|
2492
|
+
let pulseRunning = false;
|
|
2493
|
+
let holdsWorkerLock = false;
|
|
2494
|
+
let journalsRecovered = false;
|
|
2495
|
+
let consecutiveFailures = 0;
|
|
1675
2496
|
const autoProvisionSavedBindings = (apiKey, bindings) => {
|
|
1676
2497
|
for (const b of bindings) {
|
|
1677
2498
|
const projPath = (b.project_path || '').trim();
|
|
1678
2499
|
if (!projPath || !fs.existsSync(projPath))
|
|
1679
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);
|
|
1680
2503
|
const amConfigPath = path.join(projPath, '.agilemonster.json');
|
|
1681
2504
|
let needsWrite = true;
|
|
1682
2505
|
if (fs.existsSync(amConfigPath)) {
|
|
@@ -1724,11 +2547,21 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1724
2547
|
if (!fs.existsSync(claudeMcp))
|
|
1725
2548
|
fs.writeFileSync(claudeMcp, mcpPayload, 'utf8');
|
|
1726
2549
|
}
|
|
1727
|
-
catch {
|
|
2550
|
+
catch (err) {
|
|
2551
|
+
log.warn('binding.provision_failed', { project: projPath, error: errorMessage(err) });
|
|
2552
|
+
}
|
|
1728
2553
|
}
|
|
1729
2554
|
}
|
|
1730
2555
|
};
|
|
1731
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;
|
|
1732
2565
|
try {
|
|
1733
2566
|
const config = client.loadProjectConfig();
|
|
1734
2567
|
const apiKey = client.getApiKey();
|
|
@@ -1745,6 +2578,10 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1745
2578
|
discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
|
|
1746
2579
|
discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
|
|
1747
2580
|
});
|
|
2581
|
+
if (consecutiveFailures > 0) {
|
|
2582
|
+
log.info('pulse.recovered', { after_failures: consecutiveFailures });
|
|
2583
|
+
consecutiveFailures = 0;
|
|
2584
|
+
}
|
|
1748
2585
|
if (res.saved_bindings && res.saved_bindings.length > 0) {
|
|
1749
2586
|
autoProvisionSavedBindings(apiKey, res.saved_bindings);
|
|
1750
2587
|
}
|
|
@@ -1765,8 +2602,13 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1765
2602
|
}
|
|
1766
2603
|
}
|
|
1767
2604
|
// Zero-touch autonomous execution & deployment — ONLY the elected machine-wide worker may do this
|
|
1768
|
-
|
|
1769
|
-
if (
|
|
2605
|
+
holdsWorkerLock = acquireWorkerLock(role);
|
|
2606
|
+
if (holdsWorkerLock && !journalsRecovered) {
|
|
2607
|
+
journalsRecovered = true;
|
|
2608
|
+
recoverEditJournals();
|
|
2609
|
+
pruneStaleLocks();
|
|
2610
|
+
}
|
|
2611
|
+
if (holdsWorkerLock) {
|
|
1770
2612
|
const boundQueued = res.all_bound_queued_cards || [];
|
|
1771
2613
|
for (const qCard of boundQueued) {
|
|
1772
2614
|
const targetPath = (qCard.bound_project_path || '').trim() ||
|
|
@@ -1774,6 +2616,9 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1774
2616
|
if (targetPath && fs.existsSync(targetPath)) {
|
|
1775
2617
|
await executeQueuedCardAutonomously(client, qCard, targetPath);
|
|
1776
2618
|
}
|
|
2619
|
+
else if (targetPath) {
|
|
2620
|
+
log.warn('card.project_missing', { card: qCard.id, project: targetPath });
|
|
2621
|
+
}
|
|
1777
2622
|
}
|
|
1778
2623
|
const boundDone = res.all_bound_done_cards || [];
|
|
1779
2624
|
for (const dCard of boundDone) {
|
|
@@ -1783,6 +2628,12 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1783
2628
|
await deployApprovedCardIfNeeded(client, dCard, targetPath);
|
|
1784
2629
|
}
|
|
1785
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));
|
|
1786
2637
|
}
|
|
1787
2638
|
if ((res.version?.force_update_requested || res.version?.update_available) &&
|
|
1788
2639
|
!updateTriggered &&
|
|
@@ -1791,8 +2642,16 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1791
2642
|
triggerBackgroundSelfUpdate(res.version?.latest_version);
|
|
1792
2643
|
}
|
|
1793
2644
|
}
|
|
1794
|
-
catch {
|
|
1795
|
-
//
|
|
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;
|
|
1796
2655
|
}
|
|
1797
2656
|
};
|
|
1798
2657
|
setTimeout(runPulse, 2500).unref();
|
|
@@ -1801,6 +2660,9 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
1801
2660
|
return timer;
|
|
1802
2661
|
}
|
|
1803
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
|
|
1804
2666
|
const client = new AgileMonsterClient();
|
|
1805
2667
|
const server = createServer(client);
|
|
1806
2668
|
const transport = new StdioServerTransport();
|
|
@@ -1808,21 +2670,39 @@ export async function runStdioServer() {
|
|
|
1808
2670
|
startBuiltInPullPulse(server, client, 20000, 'stdio');
|
|
1809
2671
|
}
|
|
1810
2672
|
export async function runBackgroundPulseDaemon(intervalMs = 20000) {
|
|
2673
|
+
configureLogger({ echo: false });
|
|
2674
|
+
const addedPaths = await ensureUserPath();
|
|
1811
2675
|
const client = new AgileMonsterClient();
|
|
1812
2676
|
console.log(`[Agile Monster Pulse Daemon v${MCP_SERVER_VERSION}] Listening for queued agent tasks across all bound repositories (interval: ${intervalMs / 1000}s)...`);
|
|
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
|
+
});
|
|
1813
2689
|
startBuiltInPullPulse(null, client, intervalMs, 'daemon');
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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;
|
|
1817
2695
|
setInterval(() => {
|
|
1818
2696
|
try {
|
|
1819
|
-
if (
|
|
1820
|
-
const curMtime = fs.statSync(
|
|
1821
|
-
if (curMtime > initialMtime +
|
|
1822
|
-
|
|
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');
|
|
1823
2703
|
}
|
|
1824
2704
|
}
|
|
1825
2705
|
}
|
|
1826
2706
|
catch { }
|
|
1827
|
-
},
|
|
2707
|
+
}, 10000);
|
|
1828
2708
|
}
|