feat: add session auto-planning and resource-based concurrency scheduling

This commit is contained in:
lpf
2026-03-04 12:44:31 +08:00
parent ba8cfbe131
commit 154ab3f7f9
16 changed files with 1193 additions and 486 deletions

View File

@@ -0,0 +1,166 @@
package scheduling
import (
"regexp"
"strings"
)
var (
reBracketResourceKeys = regexp.MustCompile(`(?i)\[\s*resource[_\s-]*keys?\s*:\s*([^\]]+)\]`)
reBracketKeys = regexp.MustCompile(`(?i)\[\s*keys?\s*:\s*([^\]]+)\]`)
reLineResourceKeys = regexp.MustCompile(`(?i)^\s*resource[_\s-]*keys?\s*[:=]\s*(.+)$`)
)
// DeriveResourceKeys derives lock keys from content.
// Explicit directives win; otherwise lightweight heuristic extraction is used.
func DeriveResourceKeys(content string) []string {
raw := strings.TrimSpace(content)
if raw == "" {
return nil
}
if explicit := ParseExplicitResourceKeys(raw); len(explicit) > 0 {
return explicit
}
lower := strings.ToLower(raw)
keys := make([]string, 0, 8)
hasRepo := false
for _, token := range strings.Fields(lower) {
t := strings.Trim(token, "`'\"()[]{}:;,,。!?")
if t == "" {
continue
}
if strings.Contains(t, "gitea.") || strings.Contains(t, "github.com") || strings.Count(t, "/") >= 1 {
if strings.Contains(t, "github.com/") || strings.Contains(t, "gitea.") {
keys = append(keys, "repo:"+t)
hasRepo = true
}
}
if strings.Contains(t, "/") || strings.HasSuffix(t, ".go") || strings.HasSuffix(t, ".md") || strings.HasSuffix(t, ".json") || strings.HasSuffix(t, ".yaml") || strings.HasSuffix(t, ".yml") {
keys = append(keys, "file:"+t)
}
if t == "main" || strings.HasPrefix(t, "branch:") {
keys = append(keys, "branch:"+strings.TrimPrefix(t, "branch:"))
}
}
if !hasRepo {
keys = append(keys, "repo:default")
}
if len(keys) == 0 {
keys = append(keys, "scope:general")
}
return NormalizeResourceKeys(keys)
}
// ParseExplicitResourceKeys parses directive-style keys from content.
func ParseExplicitResourceKeys(content string) []string {
raw := strings.TrimSpace(content)
if raw == "" {
return nil
}
if m := reBracketResourceKeys.FindStringSubmatch(raw); len(m) == 2 {
return ParseResourceKeyList(m[1])
}
if m := reBracketKeys.FindStringSubmatch(raw); len(m) == 2 {
return ParseResourceKeyList(m[1])
}
for _, line := range strings.Split(raw, "\n") {
m := reLineResourceKeys.FindStringSubmatch(strings.TrimSpace(line))
if len(m) == 2 {
return ParseResourceKeyList(m[1])
}
}
return nil
}
// ExtractResourceKeysDirective returns explicit keys and content without directive text.
func ExtractResourceKeysDirective(content string) (keys []string, cleaned string, found bool) {
raw := strings.TrimSpace(content)
if raw == "" {
return nil, "", false
}
if m := reBracketResourceKeys.FindStringSubmatch(raw); len(m) == 2 {
keys = ParseResourceKeyList(m[1])
if len(keys) == 0 {
return nil, raw, false
}
cleaned = strings.TrimSpace(reBracketResourceKeys.ReplaceAllString(raw, ""))
return keys, cleaned, true
}
if m := reBracketKeys.FindStringSubmatch(raw); len(m) == 2 {
keys = ParseResourceKeyList(m[1])
if len(keys) == 0 {
return nil, raw, false
}
cleaned = strings.TrimSpace(reBracketKeys.ReplaceAllString(raw, ""))
return keys, cleaned, true
}
lines := strings.Split(raw, "\n")
for i, line := range lines {
m := reLineResourceKeys.FindStringSubmatch(strings.TrimSpace(line))
if len(m) != 2 {
continue
}
keys = ParseResourceKeyList(m[1])
if len(keys) == 0 {
break
}
trimmed := make([]string, 0, len(lines)-1)
trimmed = append(trimmed, lines[:i]...)
trimmed = append(trimmed, lines[i+1:]...)
cleaned = strings.TrimSpace(strings.Join(trimmed, "\n"))
return keys, cleaned, true
}
return nil, raw, false
}
// ParseResourceKeyList parses comma/newline/space separated keys.
func ParseResourceKeyList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
replacer := strings.NewReplacer("\n", ",", "", ",", ";", ",", "", ",")
normalized := replacer.Replace(raw)
parts := strings.Split(normalized, ",")
keys := make([]string, 0, len(parts))
for _, p := range parts {
k := strings.TrimSpace(strings.Trim(p, "`'\""))
if k == "" {
continue
}
if !strings.Contains(k, ":") {
k = "file:" + k
}
keys = append(keys, k)
}
if len(keys) == 0 {
for _, p := range strings.Fields(raw) {
if !strings.Contains(p, ":") {
p = "file:" + p
}
keys = append(keys, p)
}
}
return NormalizeResourceKeys(keys)
}
// NormalizeResourceKeys lowercases, trims and deduplicates keys.
func NormalizeResourceKeys(keys []string) []string {
if len(keys) == 0 {
return nil
}
out := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, k := range keys {
n := strings.ToLower(strings.TrimSpace(k))
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out
}

View File

@@ -0,0 +1,49 @@
package scheduling
import "testing"
func TestExtractResourceKeysDirective(t *testing.T) {
keys, cleaned, ok := ExtractResourceKeysDirective("[resource_keys: repo:acme/app,file:pkg/a.go]\nplease check")
if !ok {
t.Fatalf("expected directive")
}
if len(keys) != 2 || keys[0] != "repo:acme/app" || keys[1] != "file:pkg/a.go" {
t.Fatalf("unexpected keys: %#v", keys)
}
if cleaned != "please check" {
t.Fatalf("unexpected cleaned content: %q", cleaned)
}
}
func TestDeriveResourceKeysHeuristic(t *testing.T) {
keys := DeriveResourceKeys("update pkg/agent/loop.go on main")
if len(keys) == 0 {
t.Fatalf("expected non-empty keys")
}
foundFile := false
foundBranch := false
for _, k := range keys {
if k == "file:pkg/agent/loop.go" {
foundFile = true
}
if k == "branch:main" {
foundBranch = true
}
}
if !foundFile {
t.Fatalf("expected file key in %#v", keys)
}
if !foundBranch {
t.Fatalf("expected branch key in %#v", keys)
}
}
func TestParseResourceKeyListAddsFilePrefix(t *testing.T) {
keys := ParseResourceKeyList("pkg/a.go, repo:acme/app")
if len(keys) != 2 {
t.Fatalf("unexpected len: %#v", keys)
}
if keys[0] != "file:pkg/a.go" && keys[1] != "file:pkg/a.go" {
t.Fatalf("expected file-prefixed key in %#v", keys)
}
}