@programinglive/dev-workflow-mcp-server 1.13.11 → 1.13.13
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/factory-client.js +69 -14
- package/factory-credentials.js +156 -0
- package/index.js +16 -0
- package/package.json +1 -1
- package/process-lock.js +134 -0
- package/workflow-state.js +71 -0
package/factory-client.js
CHANGED
|
@@ -25,6 +25,7 @@ import os from "os";
|
|
|
25
25
|
import { assertSafeKnowledge } from './security/policy.js';
|
|
26
26
|
import { sanitize } from './security/sanitizer.js';
|
|
27
27
|
import { registerRuntimeSecret } from './security/runtime-secrets.js';
|
|
28
|
+
import { refreshFactoryCredentials, describeLoginFailure } from './factory-credentials.js';
|
|
28
29
|
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
29
30
|
import path from "path";
|
|
30
31
|
|
|
@@ -397,6 +398,27 @@ export function buildWorkFinishPayload({ sessionId, deviceId, outcome, summary,
|
|
|
397
398
|
// HTTP layer
|
|
398
399
|
// ---------------------------------------------------------------------------
|
|
399
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Kapan proses MCP ini hidup.
|
|
403
|
+
*
|
|
404
|
+
* Dipakai di pesan gagal login: tanpa ini pengguna tidak punya cara tahu bahwa
|
|
405
|
+
* environment yang dipakai sudah usang.
|
|
406
|
+
*/
|
|
407
|
+
function processStartedAt() {
|
|
408
|
+
return new Date(Date.now() - process.uptime() * 1000);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Satu percobaan login dengan pasangan email/password tertentu. */
|
|
412
|
+
function attemptLogin(config, fetchImpl, { email, password }) {
|
|
413
|
+
registerRuntimeSecret(password);
|
|
414
|
+
|
|
415
|
+
return fetchImpl(`${config.pocketBaseUrl}/api/collections/activity_users/auth-with-password`, {
|
|
416
|
+
method: "POST",
|
|
417
|
+
headers: { "content-type": "application/json" },
|
|
418
|
+
body: JSON.stringify({ identity: email, password }),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
400
422
|
async function resolveToken(config, fetchImpl = fetch) {
|
|
401
423
|
registerRuntimeSecret(config.token); registerRuntimeSecret(config.password);
|
|
402
424
|
if (config.token) return config.token;
|
|
@@ -405,24 +427,57 @@ async function resolveToken(config, fetchImpl = fetch) {
|
|
|
405
427
|
const cached = tokenCache.get(key);
|
|
406
428
|
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
407
429
|
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
430
|
+
const remember = (token) => {
|
|
431
|
+
registerRuntimeSecret(token);
|
|
432
|
+
tokenCache.set(key, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
433
|
+
return token;
|
|
434
|
+
};
|
|
413
435
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
436
|
+
const response = await attemptLogin(config, fetchImpl, config);
|
|
437
|
+
|
|
438
|
+
if (response.ok) {
|
|
439
|
+
const body = await response.json();
|
|
440
|
+
if (!body.token) throw new Error("Factory login returned no token.");
|
|
441
|
+
return remember(body.token);
|
|
418
442
|
}
|
|
419
443
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
444
|
+
// Kredensial ditolak. Sebelum menyerah, baca ulang berkas konfigurasi: proses
|
|
445
|
+
// MCP mewarisi environment saat dijalankan dan tidak pernah memuat ulang, jadi
|
|
446
|
+
// password yang baru diubah di opencode.json tidak pernah terpakai sampai
|
|
447
|
+
// OpenCode di-restart. 31 Oktober 2026 itu mematikan otomasi berjam-jam.
|
|
448
|
+
const refreshed = refreshFactoryCredentials(config);
|
|
449
|
+
|
|
450
|
+
if (refreshed && refreshed.changed) {
|
|
451
|
+
if (refreshed.token) {
|
|
452
|
+
return remember(refreshed.token);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (refreshed.email && refreshed.password) {
|
|
456
|
+
const retry = await attemptLogin(config, fetchImpl, refreshed);
|
|
457
|
+
|
|
458
|
+
if (retry.ok) {
|
|
459
|
+
const body = await retry.json();
|
|
460
|
+
if (!body.token) throw new Error("Factory login returned no token.");
|
|
461
|
+
return remember(body.token);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
throw new Error(
|
|
465
|
+
describeLoginFailure({
|
|
466
|
+
status: retry.status,
|
|
467
|
+
processStartedAt: processStartedAt(),
|
|
468
|
+
credentialsChanged: true,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
423
473
|
|
|
424
|
-
|
|
425
|
-
|
|
474
|
+
throw new Error(
|
|
475
|
+
describeLoginFailure({
|
|
476
|
+
status: response.status,
|
|
477
|
+
processStartedAt: processStartedAt(),
|
|
478
|
+
credentialsChanged: Boolean(refreshed && refreshed.changed),
|
|
479
|
+
}),
|
|
480
|
+
);
|
|
426
481
|
}
|
|
427
482
|
|
|
428
483
|
/** Test helper: forget cached tokens (for example after a password change). */
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Membaca ulang kredensial Factory dari berkas konfigurasi OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Kenapa perlu: proses MCP mewarisi environment saat ia di-spawn dan tidak
|
|
5
|
+
* pernah memuat ulang. 31 Oktober 2026 otomasi Factory mati berjam-jam dengan
|
|
6
|
+
* pesan "login failed (HTTP 400). Check FACTORY_EMAIL and FACTORY_PASSWORD"
|
|
7
|
+
* padahal password di `~/.config/opencode/opencode.json` sudah benar dan login
|
|
8
|
+
* langsung ke PocketBase menjawab 200. Penyebabnya: proses MCP hidup sejak
|
|
9
|
+
* 30 Oktober dan memegang password LAMA di memorinya.
|
|
10
|
+
*
|
|
11
|
+
* Modul ini TIDAK menyimpan rahasia baru ke disk. Ia hanya membaca berkas yang
|
|
12
|
+
* sudah ada dan sudah berisi rahasia itu.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "fs";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import path from "path";
|
|
18
|
+
|
|
19
|
+
/** Nama server di blok `mcp` pada konfigurasi OpenCode. */
|
|
20
|
+
export const DEFAULT_SERVER_NAME = "dev-workflow";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Berkas kredensial bawaan: konfigurasi OpenCode.
|
|
24
|
+
*
|
|
25
|
+
* Bisa diarahkan lewat `FACTORY_CREDENTIALS_FILE` bila konfigurasinya di tempat
|
|
26
|
+
* lain (misalnya berkas khusus tanpa isi lain).
|
|
27
|
+
*/
|
|
28
|
+
export function defaultCredentialsFile(env = process.env) {
|
|
29
|
+
const explicit = clean(env.FACTORY_CREDENTIALS_FILE);
|
|
30
|
+
if (explicit) return explicit;
|
|
31
|
+
|
|
32
|
+
const home = clean(env.HOME) || clean(env.USERPROFILE) || os.homedir();
|
|
33
|
+
const configHome = clean(env.XDG_CONFIG_HOME) || path.join(home, ".config");
|
|
34
|
+
|
|
35
|
+
return path.join(configHome, "opencode", "opencode.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clean(value) {
|
|
39
|
+
return typeof value === "string" ? value.trim() : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mengambil blok `environment` milik server MCP kita dari teks konfigurasi.
|
|
44
|
+
*
|
|
45
|
+
* Dua bentuk didukung, karena keduanya ada di lapangan:
|
|
46
|
+
* { mcp: { "<nama>": {...} } } (konfigurasi lama)
|
|
47
|
+
* { mcp: { servers: { "<nama>": {...} } } } (konfigurasi OpenCode di mesin ini)
|
|
48
|
+
*
|
|
49
|
+
* Mengembalikan null bila teks bukan JSON, tidak punya `mcp`, atau tidak punya
|
|
50
|
+
* blok server yang diminta. Mengambil server LAIN akan lebih buruk daripada
|
|
51
|
+
* tidak mengambil apa pun: kredensial proyek lain bisa terpakai.
|
|
52
|
+
*/
|
|
53
|
+
export function extractFactoryEnv(text, { serverName = DEFAULT_SERVER_NAME } = {}) {
|
|
54
|
+
let parsed;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mcp = parsed && parsed.mcp;
|
|
63
|
+
if (!mcp || typeof mcp !== "object") return null;
|
|
64
|
+
|
|
65
|
+
const block = mcp[serverName] || (mcp.servers && mcp.servers[serverName]);
|
|
66
|
+
if (!block || typeof block !== "object") return null;
|
|
67
|
+
|
|
68
|
+
const environment = block.environment;
|
|
69
|
+
if (!environment || typeof environment !== "object") return null;
|
|
70
|
+
|
|
71
|
+
return environment;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Membandingkan kredensial yang sedang dipakai proses dengan yang ada di berkas.
|
|
76
|
+
*
|
|
77
|
+
* Mengembalikan null bila tidak ada yang bisa disegarkan (berkas tidak ada,
|
|
78
|
+
* rusak, atau tanpa blok server kita). Selain itu objek dengan kredensial
|
|
79
|
+
* TERBARU, plus `changed` yang menandakan apakah ia berbeda dari yang dipakai.
|
|
80
|
+
*/
|
|
81
|
+
export function refreshFactoryCredentials(
|
|
82
|
+
config = {},
|
|
83
|
+
{ env = process.env, readFileImpl = readFileSync, serverName = DEFAULT_SERVER_NAME } = {}
|
|
84
|
+
) {
|
|
85
|
+
const file = defaultCredentialsFile(env);
|
|
86
|
+
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = String(readFileImpl(file, "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const environment = extractFactoryEnv(text, { serverName });
|
|
95
|
+
if (!environment) return null;
|
|
96
|
+
|
|
97
|
+
const token = clean(environment.FACTORY_TOKEN);
|
|
98
|
+
const email = clean(environment.FACTORY_EMAIL);
|
|
99
|
+
const password = clean(environment.FACTORY_PASSWORD);
|
|
100
|
+
|
|
101
|
+
const current = {
|
|
102
|
+
token: clean(config.token),
|
|
103
|
+
email: clean(config.email),
|
|
104
|
+
password: clean(config.password),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const changed =
|
|
108
|
+
token !== current.token || email !== current.email || password !== current.password;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
token,
|
|
112
|
+
email,
|
|
113
|
+
password,
|
|
114
|
+
changed,
|
|
115
|
+
source: "file",
|
|
116
|
+
file,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pesan gagal login yang bisa ditindaklanjuti.
|
|
122
|
+
*
|
|
123
|
+
* Dua hal yang membedakannya dari pesan lama ("check FACTORY_EMAIL and
|
|
124
|
+
* FACTORY_PASSWORD"): ia menyebut KAPAN proses ini hidup, dan ia menyebut
|
|
125
|
+
* apakah kredensial di berkas berbeda dari yang sedang dipakai. Tanpa itu,
|
|
126
|
+
* pengguna disuruh memeriksa password yang sebenarnya sudah benar.
|
|
127
|
+
*/
|
|
128
|
+
export function describeLoginFailure({ status, processStartedAt, credentialsChanged = false }) {
|
|
129
|
+
const started = processStartedAt instanceof Date ? processStartedAt.toISOString() : "";
|
|
130
|
+
|
|
131
|
+
const lines = [
|
|
132
|
+
`Factory login failed (HTTP ${status}).`,
|
|
133
|
+
started
|
|
134
|
+
? `Proses MCP ini hidup sejak ${started} dan memakai environment yang diwarisi saat itu - ia tidak pernah memuat ulang berkas konfigurasi.`
|
|
135
|
+
: "Proses MCP ini memakai environment yang diwarisi saat ia dijalankan.",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
if (credentialsChanged) {
|
|
139
|
+
lines.push(
|
|
140
|
+
"Kredensial di berkas konfigurasi BERBEDA dari yang sedang dipakai proses ini. " +
|
|
141
|
+
"Kredensial terbaru sudah dicoba dan tetap ditolak, jadi kemungkinan besar " +
|
|
142
|
+
"password-nya memang salah, atau akunnya belum bisa login."
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
lines.push(
|
|
146
|
+
"Kredensial di berkas konfigurasi SAMA dengan yang dipakai, atau berkasnya tidak ditemukan."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push(
|
|
151
|
+
"Periksa FACTORY_EMAIL/FACTORY_PASSWORD, atau set FACTORY_TOKEN. " +
|
|
152
|
+
"Bila password baru saja diubah, restart OpenCode agar proses MCP memakai kredensial terbaru."
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return lines.join(" ");
|
|
156
|
+
}
|
package/index.js
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
GetPromptRequestSchema,
|
|
14
14
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
15
|
import { fileURLToPath } from "url";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { acquireProcessLock, releaseProcessLock, lockWarning } from "./process-lock.js";
|
|
16
18
|
import { exec, setActiveProjectDir } from "./exec.js";
|
|
17
19
|
import { normalizeRequestArgs, shellEscape } from "./utils.js";
|
|
18
20
|
import { createGitFacade } from "./git-facade.js";
|
|
@@ -197,6 +199,20 @@ export async function main() {
|
|
|
197
199
|
// Select request metadata before the first disk write. Creating an idle
|
|
198
200
|
// PID fallback here leaves a phantom session alongside the real sessions.
|
|
199
201
|
safeLog('error', `[${new Date().toISOString()}] Workflow state initialization complete.`);
|
|
202
|
+
|
|
203
|
+
// Penanda proses: 31 Oktober 2026 dua proses MCP hidup bersamaan dan
|
|
204
|
+
// memegang kredensial Factory yang berbeda, sehingga tool yang sama
|
|
205
|
+
// menjawab berbeda tergantung proses mana yang melayaninya. Proses lain
|
|
206
|
+
// TIDAK dibunuh (itu berbahaya); cukup dilaporkan supaya pengguna tahu
|
|
207
|
+
// harus menutup OpenCode dan membukanya lagi.
|
|
208
|
+
try {
|
|
209
|
+
const stateDir = path.dirname(path.resolve(workflowState.stateFile));
|
|
210
|
+
const warning = lockWarning(acquireProcessLock({ dir: stateDir }));
|
|
211
|
+
if (warning) safeLog('error', warning);
|
|
212
|
+
process.once('exit', () => releaseProcessLock({ dir: stateDir }));
|
|
213
|
+
} catch (lockError) {
|
|
214
|
+
safeLog('error', 'Warning: could not record process lock:', lockError.message);
|
|
215
|
+
}
|
|
200
216
|
} catch (error) {
|
|
201
217
|
safeLog('error', `[${new Date().toISOString()}] Warning: Failed to initialize workflow state:`, error.message);
|
|
202
218
|
// Continue anyway - workflow state is optional for MCP to function
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@programinglive/dev-workflow-mcp-server",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.13",
|
|
4
4
|
"description": "MCP server to enforce development workflow discipline",
|
|
5
5
|
"homepage": "https://devworkflow.programinglive.com/",
|
|
6
6
|
"mcpName": "io.github.programinglive/dev-workflow-mcp-server",
|
package/process-lock.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satu penanda "proses MCP mana yang sedang hidup" per direktori state.
|
|
3
|
+
*
|
|
4
|
+
* Kenapa perlu: 31 Oktober 2026 ada DUA proses dev-workflow MCP hidup
|
|
5
|
+
* bersamaan. Yang lebih tua memegang password Factory lama di memorinya, yang
|
|
6
|
+
* lebih muda memegang yang baru, jadi tool yang sama menjawab berbeda
|
|
7
|
+
* tergantung proses mana yang melayaninya - dan keduanya menulis berkas state
|
|
8
|
+
* workflow yang sama.
|
|
9
|
+
*
|
|
10
|
+
* Modul ini TIDAK membunuh proses lain. Membunuh proses secara otomatis adalah
|
|
11
|
+
* tindakan berbahaya (ada guard tersendiri untuk itu). Yang dilakukan: proses
|
|
12
|
+
* kedua mengetahui bahwa ia bukan satu-satunya, lalu melaporkannya, sehingga
|
|
13
|
+
* pengguna bisa menutup OpenCode dan memulai ulang dengan bersih.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import path from "path";
|
|
19
|
+
|
|
20
|
+
export const LOCK_FILENAME = "mcp-process.lock";
|
|
21
|
+
|
|
22
|
+
/** Lokasi berkas lock untuk satu direktori state. */
|
|
23
|
+
export function processLockPath({ dir }) {
|
|
24
|
+
return path.join(dir, LOCK_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Membaca isi lock, atau null bila tidak ada / tidak terbaca.
|
|
29
|
+
*
|
|
30
|
+
* Berkas rusak sengaja dianggap "tidak ada lock": penanda yang tidak bisa
|
|
31
|
+
* dibaca tidak boleh membuat server gagal start. Lebih baik dua proses
|
|
32
|
+
* bertabrakan daripada server tidak hidup sama sekali.
|
|
33
|
+
*/
|
|
34
|
+
export function readProcessLock({ dir }) {
|
|
35
|
+
const file = processLockPath({ dir });
|
|
36
|
+
|
|
37
|
+
if (!existsSync(file)) return null;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
41
|
+
if (!parsed || typeof parsed.pid !== "number") return null;
|
|
42
|
+
return parsed;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultIsAlive(pid) {
|
|
49
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Sinyal 0 tidak mengirim apa pun; ia hanya memeriksa apakah proses ada.
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// EPERM berarti proses ADA tetapi milik pengguna lain.
|
|
57
|
+
return error && error.code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mencoba menjadi pemegang lock.
|
|
63
|
+
*
|
|
64
|
+
* Mengembalikan:
|
|
65
|
+
* { acquired: true } -> kita pemegangnya
|
|
66
|
+
* { acquired: true, tookOverFrom: <pid> } -> pemegang lama sudah mati
|
|
67
|
+
* { acquired: false, holder: {...} } -> ada proses lain yang hidup
|
|
68
|
+
*/
|
|
69
|
+
export function acquireProcessLock({ dir, pid = process.pid, now = Date.now(), isAlive = defaultIsAlive }) {
|
|
70
|
+
const existing = readProcessLock({ dir });
|
|
71
|
+
|
|
72
|
+
if (existing && existing.pid !== pid && isAlive(existing.pid)) {
|
|
73
|
+
return { acquired: false, holder: existing };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tookOverFrom = existing && existing.pid !== pid ? existing.pid : undefined;
|
|
77
|
+
|
|
78
|
+
const payload = { pid, startedAt: now, host: safeHostname() };
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
writeFileSync(processLockPath({ dir }), JSON.stringify(payload), "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
// Tidak bisa menulis penanda bukan alasan mematikan server.
|
|
85
|
+
return { acquired: true, wrote: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Melepas lock hanya bila memang milik kita. */
|
|
92
|
+
export function releaseProcessLock({ dir, pid = process.pid }) {
|
|
93
|
+
const existing = readProcessLock({ dir });
|
|
94
|
+
|
|
95
|
+
if (!existing || existing.pid !== pid) return false;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
unlinkSync(processLockPath({ dir }));
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function safeHostname() {
|
|
106
|
+
try {
|
|
107
|
+
return os.hostname();
|
|
108
|
+
} catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pesan yang dicatat saat proses lain sudah memegang lock, atau null bila tidak
|
|
115
|
+
* ada yang perlu dilaporkan.
|
|
116
|
+
*
|
|
117
|
+
* Dipisah dari `acquireProcessLock` supaya bisa diuji tanpa menyentuh proses
|
|
118
|
+
* nyata, dan supaya pemanggil bebas memilih tidak menampilkan apa pun.
|
|
119
|
+
*/
|
|
120
|
+
export function lockWarning(result) {
|
|
121
|
+
if (!result || result.acquired !== false || !result.holder) return null;
|
|
122
|
+
|
|
123
|
+
const started = Number.isFinite(result.holder.startedAt)
|
|
124
|
+
? new Date(result.holder.startedAt).toISOString()
|
|
125
|
+
: "waktu tidak diketahui";
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
`Proses dev-workflow MCP lain (PID ${result.holder.pid}, hidup sejak ${started}) ` +
|
|
129
|
+
"masih berjalan dan memakai direktori state yang sama. " +
|
|
130
|
+
"Proses yang lebih tua memegang environment saat ia dijalankan, jadi kredensial Factory " +
|
|
131
|
+
"dan state workflow bisa berbeda antar proses. Tutup OpenCode lalu buka lagi untuk " +
|
|
132
|
+
"memastikan hanya satu proses yang hidup."
|
|
133
|
+
);
|
|
134
|
+
}
|
package/workflow-state.js
CHANGED
|
@@ -33,6 +33,22 @@ let cachedUserStateDir = null;
|
|
|
33
33
|
*/
|
|
34
34
|
const PROCESS_TOKEN = `proc-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Langkah workflow yang hanya boleh MAJU dalam satu task.
|
|
38
|
+
*
|
|
39
|
+
* Hanya flag PENCAPAIAN. Gerbang kualitas (`readyToCommit`,
|
|
40
|
+
* `readyCheckCompleted`, `commitAndPushCompleted`, `released`) SENGAJA tidak
|
|
41
|
+
* masuk: keempatnya direset ketika langkah sebelumnya diulang, dan memaksa
|
|
42
|
+
* nilainya tetap true akan melemahkan gerbang yang justru ingin ditegakkan.
|
|
43
|
+
*/
|
|
44
|
+
const MONOTONIC_STEP_FIELDS = [
|
|
45
|
+
"featureFlowCreated",
|
|
46
|
+
"bugFixed",
|
|
47
|
+
"testsCreated",
|
|
48
|
+
"testsPassed",
|
|
49
|
+
"documentationCreated",
|
|
50
|
+
];
|
|
51
|
+
|
|
36
52
|
function migrateOwnerMetadata(value) {
|
|
37
53
|
if(value?.owner && !value.owner.processMarker && /^proc-[A-Za-z0-9-]+$/.test(value.owner.token || '')) {
|
|
38
54
|
value.owner.processMarker=value.owner.token;
|
|
@@ -820,6 +836,44 @@ export class WorkflowState {
|
|
|
820
836
|
return created;
|
|
821
837
|
}
|
|
822
838
|
|
|
839
|
+
/**
|
|
840
|
+
* Gabungkan kemajuan langkah dari disk bila task-nya sama.
|
|
841
|
+
*
|
|
842
|
+
* Hanya menyentuh flag yang MONOTON dalam satu task. Field yang bisa turun
|
|
843
|
+
* dengan sengaja (mis. `readyCheckCompleted` yang direset `create_tests`)
|
|
844
|
+
* tidak digabung, karena itu bagian dari alur normal satu proses.
|
|
845
|
+
*
|
|
846
|
+
* `reconciled` sudah memuat state disk terbaru; `this.state` adalah milik
|
|
847
|
+
* proses ini. Kalau task berbeda atau disk idle, tidak ada yang digabung:
|
|
848
|
+
* itu task lain, atau task baru yang memang harus mulai dari nol.
|
|
849
|
+
*/
|
|
850
|
+
mergeMonotonicProgress(reconciled) {
|
|
851
|
+
const diskTask = (reconciled?.diskTask || "").trim();
|
|
852
|
+
const ourTask = (this.state.taskDescription || "").trim();
|
|
853
|
+
if (!diskTask || !ourTask || diskTask !== ourTask) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if ((reconciled?.diskPhase || "idle") === "idle") {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Harus dari `reconciled` yang BARU dibaca. `lastReconcile` adalah hasil
|
|
861
|
+
// penyimpanan sebelumnya, sehingga bisa tertinggal satu putaran - dan
|
|
862
|
+
// justru itu yang membuat penggabungan gagal menangkap langkah terbaru.
|
|
863
|
+
const disk = reconciled?.diskState;
|
|
864
|
+
if (!disk) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Kemajuan hanya boleh maju. Sekali sebuah langkah tercapai di proses mana
|
|
869
|
+
// pun, langkah itu tetap tercapai.
|
|
870
|
+
for (const field of MONOTONIC_STEP_FIELDS) {
|
|
871
|
+
if (disk[field] === true && this.state[field] !== true) {
|
|
872
|
+
this.state[field] = true;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
823
877
|
async save(options = {}) {
|
|
824
878
|
assertSafeKnowledge(this.state);
|
|
825
879
|
const reconciled = await this.reconcileWithDisk();
|
|
@@ -839,6 +893,19 @@ export class WorkflowState {
|
|
|
839
893
|
}
|
|
840
894
|
this.lastConflict = null;
|
|
841
895
|
|
|
896
|
+
// Dua proses MCP bisa melayani sesi yang SAMA (OpenCode men-spawn lebih dari
|
|
897
|
+
// satu instance), dan keduanya menulis berkas state yang sama. Tanpa ini,
|
|
898
|
+
// proses yang memegang state basi menimpa langkah yang sudah dicapai proses
|
|
899
|
+
// lain: `mark_bug_fixed` menjawab sukses, lalu `create_tests` menjawab
|
|
900
|
+
// "mark your feature/bug as fixed first". Gejalanya intermiten, sehingga
|
|
901
|
+
// mudah disalahkan pada hal lain.
|
|
902
|
+
//
|
|
903
|
+
// Langkah workflow bersifat monoton dalam satu task: sekali tercapai, tidak
|
|
904
|
+
// boleh mundur. Karena itu digabung dengan OR - tetapi HANYA bila task di
|
|
905
|
+
// disk memang task yang sama. `start_task` mengganti task, dan pada saat itu
|
|
906
|
+
// flag harus benar-benar direset, bukan dihidupkan kembali.
|
|
907
|
+
this.mergeMonotonicProgress(reconciled);
|
|
908
|
+
|
|
842
909
|
this.state.owner = {
|
|
843
910
|
processMarker: PROCESS_TOKEN,
|
|
844
911
|
pid: process.pid,
|
|
@@ -988,11 +1055,15 @@ export class WorkflowState {
|
|
|
988
1055
|
foreignToken: null,
|
|
989
1056
|
diskTask: "",
|
|
990
1057
|
diskPhase: "idle",
|
|
1058
|
+
// Dipakai mergeMonotonicProgress: state disk apa adanya, supaya flag
|
|
1059
|
+
// langkah yang sudah dicapai proses lain bisa digabung.
|
|
1060
|
+
diskState: null,
|
|
991
1061
|
};
|
|
992
1062
|
const disk = await this.readDiskState();
|
|
993
1063
|
if (!disk) {
|
|
994
1064
|
return result;
|
|
995
1065
|
}
|
|
1066
|
+
result.diskState = disk;
|
|
996
1067
|
|
|
997
1068
|
result.diskTask = disk.taskDescription || "";
|
|
998
1069
|
result.diskPhase = disk.currentPhase || "idle";
|